diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44c38afdec6..29d5a95ea01 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,7 +62,7 @@ "json.schemas": [ { "fileMatch": ["homeassistant/components/*/manifest.json"], - "url": "./script/json_schemas/manifest_schema.json" + "url": "${containerWorkspaceFolder}/script/json_schemas/manifest_schema.json" } ] } diff --git a/.gitattributes b/.gitattributes index eca98fc228f..6a18819be9d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,14 @@ *.pcm binary Dockerfile.dev linguist-language=Dockerfile + +# Generated files +CODEOWNERS linguist-generated=true +Dockerfile linguist-generated=true +homeassistant/generated/*.py linguist-generated=true +mypy.ini linguist-generated=true +requirements.txt linguist-generated=true +requirements_all.txt linguist-generated=true +requirements_test_all.txt linguist-generated=true +requirements_test_pre_commit.txt linguist-generated=true +script/hassfest/docker/Dockerfile linguist-generated=true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23365feffb7..792dacd8032 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -46,6 +46,8 @@ - This PR fixes or closes issue: fixes # - This PR is related to issue: - Link to documentation pull request: +- Link to developer documentation pull request: +- Link to frontend pull request: ## Checklist 2: Use config entry ID as base for unique IDs. @@ -160,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def _async_migrate_device_identifiers( - hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None + hass: HomeAssistant, + config_entry: MinecraftServerConfigEntry, + old_unique_id: str | None, ) -> None: """Migrate the device identifiers to the new format.""" device_registry = dr.async_get(hass) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 60f2e00da0e..a7279040a6d 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MinecraftServerCoordinator +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity KEY_STATUS = "status" @@ -24,14 +22,17 @@ BINARY_SENSOR_DESCRIPTIONS = [ ), ] +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: MinecraftServerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add binary sensor entities. async_add_entities( @@ -49,7 +50,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: BinarySensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize binary sensor base entity.""" super().__init__(coordinator, config_entry) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 3ffdc33f3b2..d0f7cf5a8fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -8,10 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Prepare config entry data. config_data = { - CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address, } @@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, vol.Required( CONF_ADDRESS, default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index e7a58741696..35a1c0dd5a5 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,7 +1,5 @@ """Constants for the Minecraft Server integration.""" -DEFAULT_NAME = "Minecraft Server" - DOMAIN = "minecraft_server" KEY_LATENCY = "latency" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 37eeb9f2ac2..457b0700535 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -5,16 +5,23 @@ from __future__ import annotations from datetime import timedelta import logging +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( MinecraftServer, + MinecraftServerAddressError, MinecraftServerConnectionError, MinecraftServerData, MinecraftServerNotInitializedError, + MinecraftServerType, ) +type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator] + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -23,17 +30,40 @@ _LOGGER = logging.getLogger(__name__) class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - def __init__(self, hass: HomeAssistant, name: str, api: MinecraftServer) -> None: + config_entry: MinecraftServerConfigEntry + _api: MinecraftServer + + def __init__( + self, + hass: HomeAssistant, + config_entry: MinecraftServerConfigEntry, + ) -> None: """Initialize coordinator instance.""" - self._api = api super().__init__( hass=hass, - name=name, + name=config_entry.title, + config_entry=config_entry, logger=_LOGGER, update_interval=SCAN_INTERVAL, ) + async def _async_setup(self) -> None: + """Set up the Minecraft Server data coordinator.""" + + # Create API instance. + self._api = MinecraftServer( + self.hass, + self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + self.config_entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. + try: + await self._api.async_initialize() + except MinecraftServerAddressError as error: + raise ConfigEntryNotReady(f"Initialization failed: {error}") from error + async def _async_update_data(self) -> MinecraftServerData: """Get updated data from the server.""" try: diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 0bcffe1434a..dd94411b969 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,20 +5,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import MinecraftServerConfigEntry -TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} +TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MinecraftServerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "config_entry": { diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index d6ade4853c9..be399a3c8dc 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], + "quality_scale": "silver", "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml new file mode 100644 index 00000000000..288e58fad39 --- /dev/null +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -0,0 +1,103 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if either the initialization or + refresh of coordinator isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency mcstatus, + the server address is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication is required for the integration. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A minecraft server can only have one device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: + status: done + comment: | + Lookup API of the dependency mcstatus for Bedrock Edition servers is not async, + but is non-blocking and therefore OK to be called. Refer to mcstatus FAQ + https://mcstatus.readthedocs.io/en/stable/pages/faq/#why-doesn-t-bedrockserver-have-an-async-lookup-method + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: done diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index fae004a015e..cfc16c7724d 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,15 +7,14 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .api import MinecraftServerData, MinecraftServerType -from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator +from .const import KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" @@ -31,6 +30,9 @@ KEY_VERSION = "version" UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MinecraftServerSensorEntityDescription(SensorEntityDescription): @@ -158,11 +160,11 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: MinecraftServerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add sensor entities. async_add_entities( @@ -184,7 +186,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize sensor base entity.""" super().__init__(coordinator, config_entry) diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c084c9e6df0..cb4670dcac4 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -2,12 +2,14 @@ "config": { "step": { "user": { - "title": "Link your Minecraft Server", - "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { - "name": "[%key:common::config_flow::data::name%]", "address": "Server address" - } + }, + "data_description": { + "address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port." + }, + "title": "Link your Minecraft Server", + "description": "Set up your Minecraft Server instance to allow monitoring." } }, "abort": { diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py index 57a9632a6ff..18a82f3a8ed 100644 --- a/homeassistant/components/minio/__init__.py +++ b/homeassistant/components/minio/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .minio_helper import MinioEventThread, create_minio_client diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index dcb2eff2fd6..c60f1c4d760 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -27,7 +27,7 @@ from homeassistant.helpers.aiohttp_client import ( async_get_clientsession, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER @@ -39,7 +39,7 @@ BUFFER_SIZE = 102400 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a MJPEG IP Camera based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/moat/sensor.py b/homeassistant/components/moat/sensor.py index 66edfbe91f2..e968577d789 100644 --- a/homeassistant/components/moat/sensor.py +++ b/homeassistant/components/moat/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -105,7 +105,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Moat BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index e19e00b1277..8f8b8d97295 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_SENSOR_ATTRIBUTES, @@ -28,7 +28,7 @@ from .entity import MobileAppEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up mobile app binary sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 7e84930e2e9..7e5a0a291b6 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -33,7 +33,9 @@ ATTR_KEYS = (ATTR_ALTITUDE, ATTR_COURSE, ATTR_SPEED, ATTR_VERTICAL_ACCURACY) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Mobile app based off an entry.""" entity = MobileAppEntity(entry) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index c0efd302c47..1980c80ce69 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_APP_DATA, diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 06ab924aba2..8200ad1fccd 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, UnitOfTemperatur from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -36,7 +36,7 @@ from .webhook import _extract_sensor_unique_id async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up mobile app sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/mochad/__init__.py b/homeassistant/components/mochad/__init__.py index c8714c902a3..9e992b5babd 100644 --- a/homeassistant/components/mochad/__init__.py +++ b/homeassistant/components/mochad/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a7b32119917..61df7206402 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import cast import voluptuous as vol @@ -49,7 +48,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service @@ -91,6 +90,7 @@ from .const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_MAX_TEMP, @@ -143,7 +143,7 @@ from .const import ( UDP, DataType, ) -from .modbus import ModbusHub, async_modbus_setup +from .modbus import DATA_MODBUS_HUBS, ModbusHub, async_modbus_setup from .validators import ( duplicate_fan_mode_validator, duplicate_swing_mode_validator, @@ -259,7 +259,8 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, - vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int, + vol.Exclusive(CONF_HVAC_ONOFF_REGISTER, "hvac_onoff_type"): cv.positive_int, vol.Optional( CONF_HVAC_ON_VALUE, default=DEFAULT_HVAC_ON_VALUE ): cv.positive_int, @@ -458,7 +459,7 @@ CONFIG_SCHEMA = vol.Schema( def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: """Return modbus hub with name.""" - return cast(ModbusHub, hass.data[DOMAIN][name]) + return hass.data[DATA_MODBUS_HUBS][name] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -468,12 +469,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload Modbus.""" - if DOMAIN not in hass.data: + if DATA_MODBUS_HUBS not in hass.data: _LOGGER.error("Modbus cannot reload, because it was never loaded") return - hubs = hass.data[DOMAIN] - for name in hubs: - await hubs[name].async_close() + hubs = hass.data[DATA_MODBUS_HUBS] + for hub in hubs.values(): + await hub.async_close() reset_platforms = async_get_platforms(hass, DOMAIN) for reset_platform in reset_platforms: _LOGGER.debug("Reload modbus resetting platform: %s", reset_platform.domain) @@ -487,7 +488,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) - return await async_modbus_setup( - hass, - config, - ) + return await async_modbus_setup(hass, config) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 97ade53762b..28d1be24587 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any @@ -104,17 +103,13 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the sensor.""" # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) - self._call_active = False if result is None: self._attr_available = False self._result = [] @@ -126,7 +121,6 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self._result = result.registers self._attr_is_on = bool(self._result[0] & 1) - self.async_write_ha_state() if self._coordinator: self._coordinator.async_set_updated_data(self._result) @@ -159,7 +153,6 @@ class SlaveSensor( """Handle entity which will be added.""" if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - self.async_write_ha_state() await super().async_added_to_hass() @callback diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index ba09bd08377..fca1b94611a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging import struct from typing import Any, cast @@ -44,7 +43,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .const import ( + CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_COIL, CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, @@ -71,6 +72,7 @@ from .const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -113,15 +115,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus climate.""" - if discovery_info is None: + if discovery_info is None or not (climates := discovery_info[CONF_CLIMATES]): return - - entities = [] - for entity in discovery_info[CONF_CLIMATES]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hass, hub, entity)) - - async_add_entities(entities) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusThermostat(hass, hub, config) for config in climates) class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): @@ -260,6 +257,13 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): else: self._hvac_onoff_register = None + if CONF_HVAC_ONOFF_COIL in config: + self._hvac_onoff_coil = config[CONF_HVAC_ONOFF_COIL] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVACMode.OFF) + else: + self._hvac_onoff_coil = None + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() @@ -293,6 +297,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) + if self._hvac_onoff_coil is not None: + # Turn HVAC Off by writing 0 to the On/Off coil, or 1 otherwise. + await self._hub.async_pb_call( + self._slave, + self._hvac_onoff_coil, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_COIL, + ) + if self._hvac_mode_register is not None: # Write a value to the mode register for the desired mode. for value, mode in self._hvac_mode_mapping: @@ -313,7 +326,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): ) break - await self.async_update() + await self._async_update_write_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" @@ -335,7 +348,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) - await self.async_update() + await self._async_update_write_state() async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing mode.""" @@ -358,7 +371,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) break - await self.async_update() + await self._async_update_write_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -413,9 +426,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTERS, ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -490,7 +503,10 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if onoff == self._hvac_off_value: self._attr_hvac_mode = HVACMode.OFF - self.async_write_ha_state() + if self._hvac_onoff_coil is not None: + onoff = await self._async_read_coil(self._hvac_onoff_coil) + if onoff == 0: + self._attr_hvac_mode = HVACMode.OFF async def _async_read_register( self, register_type: str, register: int, raw: bool | None = False @@ -516,3 +532,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): return None self._attr_available = True return float(self._value) + + async def _async_read_coil(self, address: int) -> int | None: + result = await self._hub.async_pb_call(self._slave, address, 1, CALL_TYPE_COIL) + if result is not None and result.bits is not None: + self._attr_available = True + return int(result.bits[0]) + self._attr_available = False + return None diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e11e15fff20..5926569040d 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -62,6 +62,7 @@ CONF_HVAC_MODE_REGISTER = "hvac_mode_register" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_ON_VALUE = "hvac_on_value" CONF_HVAC_OFF_VALUE = "hvac_off_value" +CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index eb9dac58900..5e7b008ff7c 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime from typing import Any from homeassistant.components.cover import CoverEntity, CoverEntityFeature, CoverState @@ -37,15 +36,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus cover.""" - if discovery_info is None: + if discovery_info is None or not (covers := discovery_info[CONF_COVERS]): return - - covers = [] - for cover in discovery_info[CONF_COVERS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hass, hub, cover)) - - async_add_entities(covers) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusCover(hass, hub, config) for config in covers) class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): @@ -117,7 +111,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._write_address, self._state_open, self._write_type ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" @@ -125,9 +119,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._write_address, self._state_closed, self._write_type ) self._attr_available = result is not None - await self.async_update() + await self._async_update_write_state() - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval @@ -136,11 +130,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): ) if result is None: self._attr_available = False - self.async_write_ha_state() return self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) else: self._set_attr_state(int(result.registers[0])) - self.async_write_ha_state() diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 90833516e59..4684c2f2b8a 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod +import asyncio from collections.abc import Callable from datetime import datetime, timedelta import logging @@ -73,71 +74,110 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" + _value: str | None = None + _attr_should_poll = False + _attr_available = True + _attr_unit_of_measurement = None + def __init__( self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] ) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE) or entry.get(CONF_DEVICE_ADDRESS, 0) + if (conf_slave := entry.get(CONF_SLAVE)) is not None: + self._slave = conf_slave + else: + self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] - self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) - self._call_active = False self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None self._attr_unique_id = entry.get(CONF_UNIQUE_ID) self._attr_name = entry[CONF_NAME] - self._attr_should_poll = False self._attr_device_class = entry.get(CONF_DEVICE_CLASS) - self._attr_available = True - self._attr_unit_of_measurement = None def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: return None - assert isinstance( - val, (float, int) - ), f"Expected float or int but {config_name} was {type(val)}" + assert isinstance(val, (float, int)), ( + f"Expected float or int but {config_name} was {type(val)}" + ) return val self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) self._nan_value = entry.get(CONF_NAN_VALUE) self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) + self._update_lock = asyncio.Lock() @abstractmethod - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Virtual function to be overwritten.""" + async def async_update(self, now: datetime | None = None) -> None: + """Update the entity state.""" + async with self._update_lock: + await self._async_update() + + async def _async_update_write_state(self) -> None: + """Update the entity state and write it to the state machine.""" + await self.async_update() + self.async_write_ha_state() + + async def _async_update_if_not_in_progress( + self, now: datetime | None = None + ) -> None: + """Update the entity state if not already in progress.""" + if self._update_lock.locked(): + _LOGGER.debug("Update for entity %s is already in progress", self.name) + return + await self._async_update_write_state() + @callback def async_run(self) -> None: """Remote start entity.""" - self.async_hold(update=False) - self._cancel_call = async_call_later( - self.hass, timedelta(milliseconds=100), self.async_update - ) + self._async_cancel_update_polling() + self._async_schedule_future_update(0.1) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( - self.hass, self.async_update, timedelta(seconds=self._scan_interval) + self.hass, + self._async_update_if_not_in_progress, + timedelta(seconds=self._scan_interval), ) self._attr_available = True self.async_write_ha_state() @callback - def async_hold(self, update: bool = True) -> None: - """Remote stop entity.""" + def _async_schedule_future_update(self, delay: float) -> None: + """Schedule an update in the future.""" + self._async_cancel_future_pending_update() + self._cancel_call = async_call_later( + self.hass, delay, self._async_update_if_not_in_progress + ) + + @callback + def _async_cancel_future_pending_update(self) -> None: + """Cancel a future pending update.""" if self._cancel_call: self._cancel_call() self._cancel_call = None + + def _async_cancel_update_polling(self) -> None: + """Cancel the polling.""" if self._cancel_timer: self._cancel_timer() self._cancel_timer = None - if update: - self._attr_available = False - self.async_write_ha_state() + + @callback + def async_hold(self) -> None: + """Remote stop entity.""" + self._async_cancel_future_pending_update() + self._async_cancel_update_polling() + self._attr_available = False + self.async_write_ha_state() async def async_base_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -312,6 +352,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._attr_is_on = True elif state.state == STATE_OFF: self._attr_is_on = False + await super().async_added_to_hass() async def async_turn(self, command: int) -> None: """Evaluate switch result.""" @@ -330,34 +371,29 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): return if self._verify_delay: - async_call_later(self.hass, self._verify_delay, self.async_update) - else: - await self.async_update() + self._async_schedule_future_update(self._verify_delay) + return + + await self._async_update_write_state() async def async_turn_off(self, **kwargs: Any) -> None: """Set switch off.""" await self.async_turn(self._command_off) - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the entity state.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval if not self._verify_active: self._attr_available = True - self.async_write_ha_state() return # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._verify_address, 1, self._verify_type ) - self._call_active = False if result is None: self._attr_available = False - self.async_write_ha_state() return self._attr_available = True @@ -379,4 +415,3 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): self._verify_address, value, ) - self.async_write_ha_state() diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index bed8ff102bb..8636ef4521a 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -25,14 +25,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus fans.""" - if discovery_info is None: + if discovery_info is None or not (fans := discovery_info[CONF_FANS]): return - fans = [] - - for entry in discovery_info[CONF_FANS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hass, hub, entry)) - async_add_entities(fans) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusFan(hass, hub, config) for config in fans) class ModbusFan(BaseSwitch, FanEntity): diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 42745c2bb78..ce1c881733e 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .entity import BaseSwitch -from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -24,14 +23,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus lights.""" - if discovery_info is None: + if discovery_info is None or not (lights := discovery_info[CONF_LIGHTS]): return - - lights = [] - for entry in discovery_info[CONF_LIGHTS]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hass, hub, entry)) - async_add_entities(lights) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusLight(hass, hub, config) for config in lights) class ModbusLight(BaseSwitch, LightEntity): diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 8c8a879ead6..006ef504590 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -30,11 +30,12 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_ADDRESS, @@ -70,50 +71,59 @@ from .const import ( from .validators import check_config _LOGGER = logging.getLogger(__name__) +DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN) -ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") # noqa: PYI024 -RunEntry = namedtuple("RunEntry", "attr func") # noqa: PYI024 +ConfEntry = namedtuple("ConfEntry", "call_type attr func_name value_attr_name") # noqa: PYI024 +RunEntry = namedtuple("RunEntry", "attr func value_attr_name") # noqa: PYI024 PB_CALL = [ ConfEntry( CALL_TYPE_COIL, "bits", "read_coils", + "count", ), ConfEntry( CALL_TYPE_DISCRETE, "bits", "read_discrete_inputs", + "count", ), ConfEntry( CALL_TYPE_REGISTER_HOLDING, "registers", "read_holding_registers", + "count", ), ConfEntry( CALL_TYPE_REGISTER_INPUT, "registers", "read_input_registers", + "count", ), ConfEntry( CALL_TYPE_WRITE_COIL, - "value", + "bits", "write_coil", + "value", ), ConfEntry( CALL_TYPE_WRITE_COILS, "count", "write_coils", + "values", ), ConfEntry( CALL_TYPE_WRITE_REGISTER, - "value", + "registers", "write_register", + "value", ), ConfEntry( CALL_TYPE_WRITE_REGISTERS, "count", "write_registers", + "values", ), ] @@ -128,14 +138,14 @@ async def async_modbus_setup( config[DOMAIN] = check_config(hass, config[DOMAIN]) if not config[DOMAIN]: return False - if DOMAIN in hass.data and config[DOMAIN] == []: - hubs = hass.data[DOMAIN] - for name in hubs: - if not await hubs[name].async_setup(): + if DATA_MODBUS_HUBS in hass.data and config[DOMAIN] == []: + hubs = hass.data[DATA_MODBUS_HUBS] + for hub in hubs.values(): + if not await hub.async_setup(): return False - hub_collect = hass.data[DOMAIN] + hub_collect = hass.data[DATA_MODBUS_HUBS] else: - hass.data[DOMAIN] = hub_collect = {} + hass.data[DATA_MODBUS_HUBS] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) @@ -322,7 +332,9 @@ class ModbusHub: for entry in PB_CALL: func = getattr(self._client, entry.func_name) - self._pb_request[entry.call_type] = RunEntry(entry.attr, func) + self._pb_request[entry.call_type] = RunEntry( + entry.attr, func, entry.value_attr_name + ) self.hass.async_create_background_task( self.async_pb_connect(), "modbus-connect" @@ -368,10 +380,18 @@ class ModbusHub: self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" - kwargs = {"slave": slave} if slave else {} + kwargs: dict[str, Any] = ( + {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + ) entry = self._pb_request[use_call] + + if use_call in {"write_registers", "write_coils"}: + if not isinstance(value, list): + value = [value] + + kwargs[entry.value_attr_name] = value try: - result: ModbusPDU = await entry.func(address, value, **kwargs) + result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index d5a16c95cc4..2c2efb70d5a 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import Any @@ -106,7 +105,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): if state: self._attr_native_value = state.native_value - async def async_update(self, now: datetime | None = None) -> None: + async def _async_update(self) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 7b55022645e..347549dc837 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -2,11 +2,11 @@ "services": { "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads all modbus entities." + "description": "Reloads all Modbus entities." }, "write_coil": { "name": "Write coil", - "description": "Writes to a modbus coil.", + "description": "Writes to a Modbus coil.", "fields": { "address": { "name": "Address", @@ -17,8 +17,8 @@ "description": "State to write." }, "slave": { - "name": "Slave", - "description": "Address of the modbus unit/slave." + "name": "Server", + "description": "Address of the Modbus unit/server." }, "hub": { "name": "Hub", @@ -28,7 +28,7 @@ }, "write_register": { "name": "Write register", - "description": "Writes to a modbus holding register.", + "description": "Writes to a Modbus holding register.", "fields": { "address": { "name": "[%key:component::modbus::services::write_coil::fields::address::name%]", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 71413391a5f..44b0575d419 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -12,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .entity import BaseSwitch -from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -24,15 +23,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Read configuration and create Modbus switches.""" - switches = [] - - if discovery_info is None: + if discovery_info is None or not (switches := discovery_info[CONF_SWITCHES]): return - - for entry in discovery_info[CONF_SWITCHES]: - hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hass, hub, entry)) - async_add_entities(switches) + hub = get_hub(hass, discovery_info[CONF_NAME]) + async_add_entities(ModbusSwitch(hass, hub, config) for config in switches) class ModbusSwitch(BaseSwitch, SwitchEntity): diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 3cad9062be9..954a638818d 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -9,13 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 98e6708a34c..237fafa69d7 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_NAME +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS @@ -30,9 +31,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): """Set up flow instance.""" self._device: str | None = None - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" dev_path = discovery_info.device unique_id = f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 00c821f3511..de8e4b2f73c 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -9,13 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index ef2bbad70ce..901e3f431a1 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -9,7 +9,7 @@ from typing import Any, Concatenate from aiomodernforms import ModernFormsConnectionError, ModernFormsError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Modern Forms device from a config entry.""" # Create Modern Forms instance for this entry - coordinator = ModernFormsDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) + coordinator = ModernFormsDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index ea903c580a4..2bba85f54d7 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import CLEAR_TIMER, DOMAIN @@ -16,7 +16,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms binary sensors.""" coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index 3c217b5747f..d10c7604722 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -7,10 +7,10 @@ from typing import Any from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -39,7 +39,7 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._handle_config_flow() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.hostname.rstrip(".") diff --git a/homeassistant/components/modern_forms/coordinator.py b/homeassistant/components/modern_forms/coordinator.py index ecd928aa922..203ba54380d 100644 --- a/homeassistant/components/modern_forms/coordinator.py +++ b/homeassistant/components/modern_forms/coordinator.py @@ -8,6 +8,8 @@ import logging from aiomodernforms import ModernFormsDevice, ModernFormsError from aiomodernforms.models import Device as ModernFormsDeviceState +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,20 +23,22 @@ _LOGGER = logging.getLogger(__name__) class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): """Class to manage fetching Modern Forms data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, - host: str, + config_entry: ConfigEntry, ) -> None: """Initialize global Modern Forms data updater.""" self.modern_forms = ModernFormsDevice( - host, session=async_get_clientsession(hass) + config_entry.data[CONF_HOST], session=async_get_clientsession(hass) ) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 988edcb60e5..26c69b28a5c 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -35,7 +35,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Modern Forms platform from config entry.""" diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 2b53a414cea..6216efe3ff4 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -36,7 +36,7 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Modern Forms platform from config entry.""" diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 0f1e90cbe52..aa7d163cfdc 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -7,7 +7,7 @@ from datetime import datetime from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -19,7 +19,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms sensor based on a config entry.""" coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index f2e8b1b705c..89a5b779d74 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import modernforms_exception_handler from .const import DOMAIN @@ -18,7 +18,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms switch based on a config entry.""" coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 244e3bc701b..b015f9a09dd 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -17,7 +17,7 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" base = Alpha2Base(entry.data[CONF_HOST]) - coordinator = Alpha2BaseCoordinator(hass, base) + coordinator = Alpha2BaseCoordinator(hass, entry, base) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 1e7018ff1c7..a7479aef5e8 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -17,7 +17,7 @@ from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 sensor entities from a config_entry.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index c7ac574724a..57f9d0e31a2 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -15,7 +15,7 @@ from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 button entities.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 7c24dad4469..85d5939049e 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2Climate entities from a config_entry.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/coordinator.py b/homeassistant/components/moehlenhoff_alpha2/coordinator.py index 2bac4b49575..50c2f9a5297 100644 --- a/homeassistant/components/moehlenhoff_alpha2/coordinator.py +++ b/homeassistant/components/moehlenhoff_alpha2/coordinator.py @@ -8,6 +8,7 @@ import logging import aiohttp from moehlenhoff_alpha2 import Alpha2Base +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -20,12 +21,17 @@ UPDATE_INTERVAL = timedelta(seconds=60) class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Keep the base instance in one place and centralize the update.""" - def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, base: Alpha2Base + ) -> None: """Initialize Alpha2Base data updater.""" self.base = base super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name="alpha2_base", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index 5286257ff61..306e80e54d3 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -4,7 +4,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -14,7 +14,7 @@ from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 sensor entities from a config_entry.""" diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 262d13ad3af..451cc65fb55 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -34,9 +34,12 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import TemperatureConverter @@ -105,7 +108,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mold indicator sensor entry.""" name: str = entry.options[CONF_NAME] diff --git a/homeassistant/components/monarch_money/__init__.py b/homeassistant/components/monarch_money/__init__.py index 5f9aba7dd07..8b7cfa6aa5b 100644 --- a/homeassistant/components/monarch_money/__init__.py +++ b/homeassistant/components/monarch_money/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from typedmonarchmoney import TypedMonarchMoney -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .coordinator import MonarchMoneyDataUpdateCoordinator - -type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] +from .coordinator import MonarchMoneyConfigEntry, MonarchMoneyDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -21,7 +18,7 @@ async def async_setup_entry( """Set up Monarch Money from a config entry.""" monarch_client = TypedMonarchMoney(token=entry.data.get(CONF_TOKEN)) - mm_coordinator = MonarchMoneyDataUpdateCoordinator(hass, monarch_client) + mm_coordinator = MonarchMoneyDataUpdateCoordinator(hass, entry, monarch_client) await mm_coordinator.async_config_entry_first_refresh() entry.runtime_data = mm_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index 5bfdc02c61e..e6ab84a4e74 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -87,7 +87,7 @@ async def validate_login( except LoginFailedException as err: raise InvalidAuth from err - LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") + LOGGER.debug("Connection successful - saving session to file %s", SESSION_FILE) LOGGER.debug("Obtaining subscription id") subs: MonarchSubscription = await monarch_client.get_subscription_details() assert subs is not None diff --git a/homeassistant/components/monarch_money/coordinator.py b/homeassistant/components/monarch_money/coordinator.py index 3e689c48e91..7f3dac9419f 100644 --- a/homeassistant/components/monarch_money/coordinator.py +++ b/homeassistant/components/monarch_money/coordinator.py @@ -30,21 +30,26 @@ class MonarchData: cashflow_summary: MonarchCashflowSummary +type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] + + class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]): """Data update coordinator for Monarch Money.""" - config_entry: ConfigEntry + config_entry: MonarchMoneyConfigEntry subscription_id: str def __init__( self, hass: HomeAssistant, + config_entry: MonarchMoneyConfigEntry, client: TypedMonarchMoney, ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name="monarchmoney", update_interval=timedelta(hours=4), ) diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json index ed28f825bcf..d45415bbcd7 100644 --- a/homeassistant/components/monarch_money/manifest.json +++ b/homeassistant/components/monarch_money/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/monarchmoney", "iot_class": "cloud_polling", - "requirements": ["typedmonarchmoney==0.3.1"] + "requirements": ["typedmonarchmoney==0.4.4"] } diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py index fe7c728cf41..1597d9820a1 100644 --- a/homeassistant/components/monarch_money/sensor.py +++ b/homeassistant/components/monarch_money/sensor.py @@ -14,10 +14,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MonarchMoneyConfigEntry +from .coordinator import MonarchMoneyConfigEntry from .entity import MonarchMoneyAccountEntity, MonarchMoneyCashFlowEntity @@ -110,7 +110,7 @@ MONARCH_CASHFLOW_SENSORS: tuple[MonarchMoneyCashflowSensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, config_entry: MonarchMoneyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Monarch Money sensors for config entries.""" mm_coordinator = config_entry.runtime_data diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 2dde0832440..9d678c16874 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_SOURCES, @@ -58,7 +58,7 @@ def _get_sources(config_entry): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Monoprice 6-zone amplifier platform.""" port = config_entry.data[CONF_PORT] diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index a88082b2ce6..662cfecd2e9 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: external_api = AuthenticatedMonzoAPI(async_get_clientsession(hass), session) - coordinator = MonzoCoordinator(hass, external_api) + coordinator = MonzoCoordinator(hass, entry, external_api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index caac551f986..06c751a23e0 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,11 +30,16 @@ class MonzoData: class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): """Class to manage fetching Monzo data from the API.""" - def __init__(self, hass: HomeAssistant, api: AuthenticatedMonzoAPI) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: AuthenticatedMonzoAPI + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=1), ) diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index 41b97d90452..0b6ab2b70a5 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import MonzoCoordinator @@ -65,7 +65,7 @@ MODEL_POT = "Pot" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 1e2674a24bf..12d0ff3ed41 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -8,8 +8,8 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -26,7 +26,7 @@ STATE_WAXING_GIBBOUS = "waxing_gibbous" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" async_add_entities([MoonSensorEntity(entry)], True) diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index 2e35ff4283f..e5b7d5d7dd2 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -111,7 +111,7 @@ class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py index 0f67efaea1e..53c93f771f2 100644 --- a/homeassistant/components/mopeka/sensor.py +++ b/homeassistant/components/mopeka/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import MopekaConfigEntry @@ -115,7 +115,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: MopekaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mopeka BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 182ea310029..2abcc273e23 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -1,18 +1,18 @@ """The motion_blinds component.""" import asyncio -from datetime import timedelta import logging from typing import TYPE_CHECKING from motionblinds import AsyncMotionMulticast -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + CONF_BLIND_TYPE_LIST, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -25,7 +25,6 @@ from .const import ( KEY_SETUP_LOCK, KEY_UNSUB_STOP, PLATFORMS, - UPDATE_INTERVAL, ) from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import ConnectMotionGateway @@ -41,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) + blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener async with setup_lock: @@ -83,7 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Connect to motion gateway multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) - if not await connect_gateway_class.async_connect_gateway(host, key): + if not await connect_gateway_class.async_connect_gateway( + host, key, blind_type_list + ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device api_lock = asyncio.Lock() @@ -94,15 +96,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } coordinator = DataUpdateCoordinatorMotionBlinds( - hass, - _LOGGER, - coordinator_info, - # Name of the data. For logging purposes. - name=entry.title, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=UPDATE_INTERVAL), + hass, entry, _LOGGER, coordinator_info ) + # store blind type list for next time + if entry.data.get(CONF_BLIND_TYPE_LIST) != motion_gateway.blind_type_list: + data = { + **entry.data, + CONF_BLIND_TYPE_LIST: motion_gateway.blind_type_list, + } + hass.config_entries.async_update_entry(entry, data=data) + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() @@ -132,12 +136,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) hass.data[DOMAIN].pop(config_entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # No motion gateways left, stop Motion multicast unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py index 89841bf8fd4..09f29e09c70 100644 --- a/homeassistant/components/motion_blinds/button.py +++ b/homeassistant/components/motion_blinds/button.py @@ -8,7 +8,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY from .coordinator import DataUpdateCoordinatorMotionBlinds @@ -18,7 +18,7 @@ from .entity import MotionCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[ButtonEntity] = [] diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index e961880375c..a7bb34af1e6 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from motionblinds import MotionDiscovery, MotionGateway import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_INTERFACE, @@ -82,7 +82,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress).replace(":", "") @@ -156,6 +156,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] + assert self._host connect_gateway_class = ConnectMotionGateway(self.hass) if not await connect_gateway_class.async_connect_gateway(self._host, key): diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 96067d7ceb0..950fa3ab4c7 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -8,6 +8,7 @@ DEFAULT_GATEWAY_NAME = "Motionblinds Gateway" PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] +CONF_BLIND_TYPE_LIST = "blind_type_list" CONF_WAIT_FOR_PUSH = "wait_for_push" CONF_INTERFACE = "interface" DEFAULT_WAIT_FOR_PUSH = False diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index b2abd205ce5..79e26e5aed4 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -7,6 +7,7 @@ from typing import Any from motionblinds import DEVICE_TYPES_WIFI, ParseException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,21 +26,22 @@ _LOGGER = logging.getLogger(__name__) class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): """Class to manage fetching data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, coordinator_info: dict[str, Any], - *, - name: str, - update_interval: timedelta, ) -> None: """Initialize global data updater.""" super().__init__( hass, logger, - name=name, - update_interval=update_interval, + config_entry=config_entry, + name=config_entry.title, + update_interval=timedelta(seconds=UPDATE_INTERVAL), ) self.api_lock = coordinator_info[KEY_API_LOCK] diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 1ea3a6ed9d6..dbf43e3d30f 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( @@ -83,7 +83,7 @@ SET_ABSOLUTE_POSITION_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Motion Blind from a config entry.""" entities: list[MotionBaseDevice] = [] diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 44f7caa74b2..9826557919c 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -42,11 +42,16 @@ class ConnectMotionGateway: for blind in self.gateway_device.device_list.values(): blind.Update_from_cache() - async def async_connect_gateway(self, host, key): + async def async_connect_gateway( + self, + host: str, + key: str, + blind_type_list: dict[str, int] | None = None, + ) -> bool: """Connect to the Motion Gateway.""" _LOGGER.debug("Initializing with host %s (key %s)", host, key[:3]) self._gateway_device = MotionGateway( - ip=host, key=key, multicast=self._multicast + ip=host, key=key, multicast=self._multicast, blind_type_list=blind_type_list ) try: # update device info and get the connected sub devices diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index b327c146300..1654d5b5937 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.25"] + "requirements": ["motionblinds==0.6.26"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 6418cebda0c..60d283aa0b6 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY from .entity import MotionCoordinatorEntity @@ -26,7 +26,7 @@ ATTR_BATTERY_VOLTAGE = "battery_voltage" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/motionblinds_ble/button.py b/homeassistant/components/motionblinds_ble/button.py index a099276cd85..12fb6c7a513 100644 --- a/homeassistant/components/motionblinds_ble/button.py +++ b/homeassistant/components/motionblinds_ble/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_CONNECT, ATTR_DISCONNECT, ATTR_FAVORITE, CONF_MAC_CODE, DOMAIN from .entity import MotionblindsBLEEntity @@ -53,7 +53,9 @@ BUTTON_TYPES: list[MotionblindsBLEButtonEntityDescription] = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities based on a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/cover.py b/homeassistant/components/motionblinds_ble/cover.py index afeeb5b0d70..beaee8598b5 100644 --- a/homeassistant/components/motionblinds_ble/cover.py +++ b/homeassistant/components/motionblinds_ble/cover.py @@ -19,7 +19,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, DOMAIN, ICON_VERTICAL_BLIND from .entity import MotionblindsBLEEntity @@ -61,7 +61,9 @@ BLIND_TYPE_TO_ENTITY_DESCRIPTION: dict[str, MotionblindsBLECoverEntityDescriptio async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover entity based on a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/select.py b/homeassistant/components/motionblinds_ble/select.py index c297c887910..976f51a0a0f 100644 --- a/homeassistant/components/motionblinds_ble/select.py +++ b/homeassistant/components/motionblinds_ble/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_SPEED, CONF_MAC_CODE, DOMAIN from .entity import MotionblindsBLEEntity @@ -32,7 +32,9 @@ SELECT_TYPES: dict[str, SelectEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities based on a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py index 740a0509a9e..8993a3b1cd5 100644 --- a/homeassistant/components/motionblinds_ble/sensor.py +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -92,7 +92,9 @@ SENSORS: tuple[MotionblindsBLESensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities based on a config entry.""" diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index df4c321037e..159956277a8 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras @@ -93,7 +93,9 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index e0113544848..c160b77c16a 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -12,7 +12,7 @@ from motioneye_client.const import KEY_ACTIONS from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 9d704f17740..89d3b8a8727 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -19,7 +19,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, listen_for_new_cameras @@ -67,7 +67,9 @@ MOTIONEYE_SWITCHES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 28963d83d89..9c2ac6fa180 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -7,13 +7,15 @@ import socket import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC +type MotionMountConfigEntry = ConfigEntry[motionmount.MotionMount] + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -22,7 +24,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MotionMountConfigEntry) -> bool: """Set up Vogel's MotionMount from a config entry.""" host = entry.data[CONF_HOST] @@ -48,18 +50,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" ) + # Check we're properly authenticated or be able to become so + if not mm.is_authenticated: + if CONF_PIN not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="no_pin_provided", + ) + + pin = entry.data[CONF_PIN] + await mm.authenticate(pin) + if not mm.is_authenticated: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="incorrect_pin", + ) + # Store an API object for your platforms to access - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + entry.runtime_data = mm await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: MotionMountConfigEntry +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + mm = entry.runtime_data await mm.disconnect() return unload_ok diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index 45b6e821440..4bb880311f9 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,19 +6,23 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountMovingSensor(mm, entry)]) @@ -28,8 +32,12 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize moving binary sensor entity.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-moving" diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 19d3557d36b..283f1f01d6e 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Vogel's MotionMount.""" +import asyncio +from collections.abc import Mapping import logging import socket from typing import Any @@ -7,14 +9,15 @@ from typing import Any import motionmount import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, + SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, EMPTY_MAC @@ -34,7 +37,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up the instance.""" - self.discovery_info: dict[str, Any] = {} + self.connection_data: dict[str, Any] = {} + self.backoff_task: asyncio.Task | None = None + self.backoff_time: int = 0 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -43,23 +48,16 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self._show_setup_form() + self.connection_data.update(user_input) info = {} try: - info = await self._validate_input(user_input) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - # This is most likely due to missing support for the mac address property - # Abort if the handler has config entries already - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - # Otherwise we try to continue with the generic uid - info[CONF_UUID] = DEFAULT_DISCOVERY_UNIQUE_ID # If the device mac is valid we use it, otherwise we use the default id if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -67,20 +65,25 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: unique_id = DEFAULT_DISCOVERY_UNIQUE_ID - name = info.get(CONF_NAME, user_input[CONF_HOST]) + name = info.get(CONF_NAME, self.connection_data[CONF_HOST]) + self.connection_data[CONF_NAME] = name await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: self.connection_data[CONF_HOST], + CONF_PORT: self.connection_data[CONF_PORT], } ) - return self.async_create_entry(title=name, data=user_input) + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed + return self._create_or_update_entry() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" @@ -91,7 +94,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): name = discovery_info.name.removesuffix(f".{zctype}") unique_id = discovery_info.properties.get("mac") - self.discovery_info.update( + self.connection_data.update( { CONF_HOST: host, CONF_PORT: port, @@ -114,16 +117,13 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update({"title_placeholders": {"name": name}}) try: - info = await self._validate_input(self.discovery_info) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - info = {} - # We continue as we want to be able to connect with older FW that does not support MAC address # If the device supplied as with a valid MAC we use that if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -137,6 +137,10 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: await self._async_handle_discovery_without_unique_id() + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( @@ -146,16 +150,82 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="zeroconf_confirm", - description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + description_placeholders={CONF_NAME: self.connection_data[CONF_NAME]}, errors={}, ) - return self.async_create_entry( - title=self.discovery_info[CONF_NAME], - data=self.discovery_info, + return self._create_or_update_entry() + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + reauth_entry = self._get_reauth_entry() + self.connection_data.update(reauth_entry.data) + return await self.async_step_auth() + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle authentication form.""" + errors = {} + + if user_input is not None: + self.connection_data[CONF_PIN] = user_input[CONF_PIN] + + # Validate pin code + valid_or_wait_time = await self._validate_input_pin(self.connection_data) + if valid_or_wait_time is True: + return self._create_or_update_entry() + + if type(valid_or_wait_time) is int: + self.backoff_time = valid_or_wait_time + self.backoff_task = self.hass.async_create_task( + self._backoff(valid_or_wait_time) + ) + return await self.async_step_backoff() + + errors[CONF_PIN] = CONF_PIN + + return self.async_show_form( + step_id="auth", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): vol.All(int, vol.Range(min=1, max=9999)), + } + ), + errors=errors, ) - async def _validate_input(self, data: dict) -> dict[str, Any]: + async def async_step_backoff( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle backoff progress.""" + if not self.backoff_task or self.backoff_task.done(): + self.backoff_task = None + return self.async_show_progress_done(next_step_id="auth") + + return self.async_show_progress( + step_id="backoff", + description_placeholders={ + "timeout": str(self.backoff_time), + }, + progress_action="progress_action", + progress_task=self.backoff_task, + ) + + def _create_or_update_entry(self) -> ConfigFlowResult: + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, data_updates=self.connection_data + ) + return self.async_create_entry( + title=self.connection_data[CONF_NAME], + data=self.connection_data, + ) + + async def _validate_input_connect(self, data: dict) -> dict[str, Any]: """Validate the user input allows us to connect.""" mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) @@ -164,7 +234,33 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): finally: await mm.disconnect() - return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + return { + CONF_UUID: format_mac(mm.mac.hex()), + CONF_NAME: mm.name, + CONF_PIN: mm.is_authenticated, + } + + async def _validate_input_pin(self, data: dict) -> bool | int: + """Validate the user input allows us to authenticate.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + await mm.authenticate(data[CONF_PIN]) + else: + # The backoff is running, return the remaining time + return can_authenticate + finally: + await mm.disconnect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + return mm.is_authenticated + + return can_authenticate def _show_setup_form( self, errors: dict[str, str] | None = None @@ -180,3 +276,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors or {}, ) + + async def _backoff(self, time: int) -> None: + while time > 0: + time -= 1 + self.backoff_time = time + await asyncio.sleep(1) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index ba81c9d10bd..bbb79729a9e 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -1,17 +1,16 @@ """Support for MotionMount sensors.""" import logging -import socket from typing import TYPE_CHECKING import motionmount -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity +from . import MotionMountConfigEntry from .const import DOMAIN, EMPTY_MAC _LOGGER = logging.getLogger(__name__) @@ -23,9 +22,16 @@ class MotionMountEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize general MotionMount entity.""" self.mm = mm + self.config_entry = config_entry + + # We store the pin, as we might need it during reconnect + self.pin = config_entry.data.get(CONF_PIN) + mac = format_mac(mm.mac.hex()) # Create a base unique id @@ -74,23 +80,3 @@ class MotionMountEntity(Entity): self.mm.remove_listener(self.async_write_ha_state) self.mm.remove_listener(self.update_name) await super().async_will_remove_from_hass() - - async def _ensure_connected(self) -> bool: - """Make sure there is a connection with the MotionMount. - - Returns false if the connection failed to be ensured. - """ - - if self.mm.is_connected: - return True - try: - await self.mm.connect() - except (ConnectionError, TimeoutError, socket.gaierror): - # We're not interested in exceptions here. In case of a failed connection - # the try/except from the caller will report it. - # The purpose of `_ensure_connected()` is only to make sure we try to - # reconnect, where failures should not be logged each time - return False - else: - _LOGGER.warning("Successfully reconnected to MotionMount") - return True diff --git a/homeassistant/components/motionmount/icons.json b/homeassistant/components/motionmount/icons.json new file mode 100644 index 00000000000..8d6d867f4d0 --- /dev/null +++ b/homeassistant/components/motionmount/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "motionmount_error_status": { + "default": "mdi:alert-circle-outline", + "state": { + "none": "mdi:check-circle-outline" + } + } + } + } +} diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 1fa3d31cfab..337ce776b33 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -1,11 +1,12 @@ { "domain": "motionmount", "name": "Vogel's MotionMount", - "codeowners": ["@RJPoelstra"], + "codeowners": ["@laiho-vogels"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==2.2.0"], + "quality_scale": "bronze", + "requirements": ["python-MotionMount==2.3.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index b42c04a6588..3e2c1b067aa 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -5,21 +5,25 @@ import socket import motionmount from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN from .entity import MotionMountEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities( ( @@ -37,7 +41,9 @@ class MotionMountExtension(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_extension" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Extension number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-extension" @@ -66,7 +72,9 @@ class MotionMountTurn(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_turn" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Turn number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-turn" diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml new file mode 100644 index 00000000000..8b210931eaf --- /dev/null +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: Integration does register actions aside from entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single device per config entry + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not need user intervention + stale-devices: + status: exempt + comment: Integration does not support dynamic devices + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Device doesn't make http requests. + strict-typing: done diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 9b43d901a21..a8fcc84f2ec 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -7,23 +7,26 @@ import socket import motionmount from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN, WALL_PRESET_NAME from .entity import MotionMountEntity _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) +PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountPresets(mm, entry)], True) @@ -37,7 +40,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): def __init__( self, mm: motionmount.MotionMount, - config_entry: ConfigEntry, + config_entry: MotionMountConfigEntry, ) -> None: """Initialize Preset selector.""" super().__init__(mm, config_entry) @@ -51,6 +54,38 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): self._attr_options = options + async def _ensure_connected(self) -> bool: + """Make sure there is a connection with the MotionMount. + + Returns false if the connection failed to be ensured. + """ + if self.mm.is_connected: + return True + try: + await self.mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror): + # We're not interested in exceptions here. In case of a failed connection + # the try/except from the caller will report it. + # The purpose of `_ensure_connected()` is only to make sure we try to + # reconnect, where failures should not be logged each time + return False + + # Check we're properly authenticated or be able to become so + if not self.mm.is_authenticated: + if self.pin is None: + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + await self.mm.authenticate(self.pin) + if not self.mm.is_authenticated: + self.pin = None + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + + _LOGGER.debug("Successfully reconnected to MotionMount") + return True + async def async_update(self) -> None: """Get latest state from MotionMount.""" if not await self._ensure_connected(): diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 933b637b0c2..28fe921d9ac 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -1,21 +1,36 @@ """Support for MotionMount sensors.""" +from typing import Final + import motionmount +from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity +PARALLEL_UPDATES = 0 + +ERROR_MESSAGES: Final = { + MotionMountSystemError.MotorError: "motor", + MotionMountSystemError.ObstructionDetected: "obstruction", + MotionMountSystemError.TVWidthConstraintError: "tv_width_constraint", + MotionMountSystemError.HDMICECError: "hdmi_cec", + MotionMountSystemError.InternalError: "internal", +} + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities((MotionMountErrorStatusSensor(mm, entry),)) @@ -24,10 +39,21 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): """The error status sensor of a MotionMount.""" _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["none", "motor", "internal"] + _attr_options = [ + "none", + "motor", + "hdmi_cec", + "obstruction", + "tv_width_constraint", + "internal", + ] _attr_translation_key = "motionmount_error_status" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize sensor entiry.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-error-status" @@ -35,13 +61,10 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): @property def native_value(self) -> str: """Return error status.""" - errors = self.mm.error_status or 0 + status = self.mm.system_status - if errors & (1 << 31): - # Only when but 31 is set are there any errors active at this moment - if errors & (1 << 10): - return "motor" - - return "internal" + for error, message in ERROR_MESSAGES.items(): + if error in status: + return message return "none" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bd28156607c..75fd0773322 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -1,4 +1,7 @@ { + "common": { + "incorrect_pin": "PIN is not correct" + }, "config": { "flow_title": "{name}", "step": { @@ -8,20 +11,45 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the MotionMount.", + "port": "The port of the MotionMount." } }, "zeroconf_confirm": { "description": "Do you want to set up {name}?", "title": "Discovered MotionMount" + }, + "auth": { + "title": "Authenticate to your MotionMount", + "description": "Your MotionMount requires a PIN to operate.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The user level PIN configured on the MotionMount." + } + }, + "backoff": { + "title": "Authenticate to your MotionMount", + "description": "Too many incorrect PIN attempts." } }, + "error": { + "pin": "[%key:component::motionmount::common::incorrect_pin%]" + }, + "progress": { + "progress_action": "Too many incorrect PIN attempts. Please wait {timeout} s..." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "time_out": "Failed to connect due to a time out.", + "time_out": "[%key:common::config_flow::error::timeout_connect%]", "not_connected": "Failed to connect.", - "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + "invalid_response": "Failed to connect due to an invalid response from the MotionMount.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -44,6 +72,9 @@ "state": { "none": "None", "motor": "Motor", + "hdmi_cec": "HDMI CEC", + "obstruction": "Obstruction", + "tv_width_constraint": "TV width constraint", "internal": "Internal" } } @@ -60,6 +91,12 @@ "exceptions": { "failed_communication": { "message": "Failed to communicate with MotionMount" + }, + "no_pin_provided": { + "message": "No PIN provided" + }, + "incorrect_pin": { + "message": "[%key:component::motionmount::common::incorrect_pin%]" } } } diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index a79d933a782..14b69e941b7 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -29,11 +29,10 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN, LOGGER @@ -69,7 +68,9 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up media player from config_entry.""" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 624f99d350a..6656afe2c8a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -38,7 +38,7 @@ from homeassistant.util.async_ import create_eager_task # Loading the config flow file will register the flow from . import debug_info, discovery -from .client import ( # noqa: F401 +from .client import ( MQTT, async_publish, async_subscribe, @@ -46,9 +46,9 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA from .config_integration import CONFIG_SCHEMA_BASE -from .const import ( # noqa: F401 +from .const import ( ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, @@ -69,6 +69,8 @@ from .const import ( # noqa: F401 CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, @@ -76,10 +78,11 @@ from .const import ( # noqa: F401 DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, + ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, ) -from .models import ( # noqa: F401 +from .models import ( DATA_MQTT, DATA_MQTT_AVAILABLE, MqttCommandTemplate, @@ -90,13 +93,13 @@ from .models import ( # noqa: F401 ReceiveMessage, convert_outgoing_mqtt_payload, ) -from .subscription import ( # noqa: F401 +from .subscription import ( EntitySubscription, async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, ) -from .util import ( # noqa: F401 +from .util import ( async_create_certificate_temp_files, async_forward_entry_setup_and_setup_discovery, async_wait_for_mqtt_client, @@ -107,6 +110,83 @@ from .util import ( # noqa: F401 valid_subscribe_topic, ) +__all__ = [ + "ATTR_PAYLOAD", + "ATTR_QOS", + "ATTR_RETAIN", + "ATTR_TOPIC", + "CONFIG_ENTRY_MINOR_VERSION", + "CONFIG_ENTRY_VERSION", + "CONF_BIRTH_MESSAGE", + "CONF_BROKER", + "CONF_CERTIFICATE", + "CONF_CLIENT_CERT", + "CONF_CLIENT_KEY", + "CONF_COMMAND_TOPIC", + "CONF_DISCOVERY_PREFIX", + "CONF_KEEPALIVE", + "CONF_QOS", + "CONF_STATE_TOPIC", + "CONF_TLS_INSECURE", + "CONF_TOPIC", + "CONF_TRANSPORT", + "CONF_WILL_MESSAGE", + "CONF_WS_HEADERS", + "CONF_WS_PATH", + "DATA_MQTT", + "DATA_MQTT_AVAILABLE", + "DEFAULT_DISCOVERY", + "DEFAULT_ENCODING", + "DEFAULT_PREFIX", + "DEFAULT_QOS", + "DEFAULT_RETAIN", + "DOMAIN", + "ENTITY_PLATFORMS", + "ENTRY_OPTION_FIELDS", + "MQTT", + "MQTT_BASE_SCHEMA", + "MQTT_CONNECTION_STATE", + "MQTT_RO_SCHEMA", + "MQTT_RW_SCHEMA", + "SERVICE_RELOAD", + "TEMPLATE_ERRORS", + "EntitySubscription", + "MqttCommandTemplate", + "MqttData", + "MqttValueTemplate", + "PayloadSentinel", + "PublishPayloadType", + "ReceiveMessage", + "SetupPhases", + "async_check_config_schema", + "async_create_certificate_temp_files", + "async_forward_entry_setup_and_setup_discovery", + "async_migrate_entry", + "async_prepare_subscribe_topics", + "async_publish", + "async_remove_config_entry_device", + "async_setup", + "async_setup_entry", + "async_subscribe", + "async_subscribe_connection_status", + "async_subscribe_topics", + "async_unload_entry", + "async_unsubscribe_topics", + "async_wait_for_mqtt_client", + "convert_outgoing_mqtt_payload", + "create_eager_task", + "is_connected", + "mqtt_config_entry_enabled", + "platforms_from_config", + "publish", + "subscribe", + "valid_publish_topic", + "valid_qos_schema", + "valid_subscribe_topic", + "websocket_mqtt_info", + "websocket_subscribe", +] + _LOGGER = logging.getLogger(__name__) SERVICE_PUBLISH = "publish" @@ -156,7 +236,7 @@ CONFIG_SCHEMA = vol.Schema( MQTT_PUBLISH_SCHEMA = vol.Schema( { vol.Required(ATTR_TOPIC): valid_publish_topic, - vol.Required(ATTR_PAYLOAD): cv.string, + vol.Required(ATTR_PAYLOAD, default=None): vol.Any(cv.string, None), vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, @@ -282,15 +362,45 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate the options from config entry data.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + data: dict[str, Any] = dict(entry.data) + options: dict[str, Any] = dict(entry.options) + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1 and entry.minor_version < 2: + # Can be removed when config entry is bumped to version 2.1 + # with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 + # From 2026.1 we will write version 2.1 + for key in ENTRY_OPTION_FIELDS: + if key not in data: + continue + options[key] = data.pop(key) + hass.config_entries.async_update_entry( + entry, + data=data, + options=options, + version=CONFIG_ENTRY_VERSION, + minor_version=CONFIG_ENTRY_MINOR_VERSION, + ) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - conf: dict[str, Any] mqtt_data: MqttData async def _setup_client() -> tuple[MqttData, dict[str, Any]]: """Set up the MQTT client.""" # Fetch configuration - conf = dict(entry.data) + conf = dict(entry.data | entry.options) hass_config = await conf_util.async_hass_config_yaml(hass) mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) await async_create_certificate_temp_files(hass, conf) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 65e24d5d780..2d73cc5865c 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -23,6 +23,7 @@ ABBREVIATIONS = { "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", "clr_temp_cmd_t": "color_temp_command_topic", + "clr_temp_k": "color_temp_kelvin", "clr_temp_stat_t": "color_temp_state_topic", "clr_temp_tpl": "color_temp_template", "clr_temp_val_tpl": "color_temp_value_template", @@ -92,6 +93,8 @@ ABBREVIATIONS = { "min_hum": "min_humidity", "max_mirs": "max_mireds", "min_mirs": "min_mireds", + "max_k": "max_kelvin", + "min_k": "min_kelvin", "max_temp": "max_temp", "min_temp": "min_temp", "migr_discvry": "migrate_discovery", @@ -215,10 +218,16 @@ ABBREVIATIONS = { "sup_vol": "support_volume_set", "sup_feat": "supported_features", "sup_clrm": "supported_color_modes", + "swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template", + "swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic", + "swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template", + "swing_h_mode_stat_t": "swing_horizontal_mode_state_topic", + "swing_h_modes": "swing_horizontal_modes", "swing_mode_cmd_tpl": "swing_mode_command_template", "swing_mode_cmd_t": "swing_mode_command_topic", "swing_mode_stat_tpl": "swing_mode_state_template", "swing_mode_stat_t": "swing_mode_state_topic", + "swing_modes": "swing_modes", "temp_cmd_tpl": "temperature_command_template", "temp_cmd_t": "temperature_command_topic", "temp_hi_cmd_tpl": "temperature_high_command_template", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 613f665c302..64b1a6b05fa 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components import alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, @@ -14,8 +14,8 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import subscription @@ -115,7 +115,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT alarm control panel through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 882e910d7e8..0467eb3a289 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -6,7 +6,14 @@ from functools import lru_cache from types import TracebackType from typing import Self -from paho.mqtt.client import Client as MQTTClient +from paho.mqtt.client import ( + CallbackOnConnect_v2, + CallbackOnDisconnect_v2, + CallbackOnPublish_v2, + CallbackOnSubscribe_v2, + CallbackOnUnsubscribe_v2, + Client as MQTTClient, +) _MQTT_LOCK_COUNT = 7 @@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient): that is not needed since we are running in an async event loop. """ + on_connect: CallbackOnConnect_v2 + on_disconnect: CallbackOnDisconnect_v2 + on_publish: CallbackOnPublish_v2 + on_subscribe: CallbackOnSubscribe_v2 + on_unsubscribe: CallbackOnUnsubscribe_v2 + def setup(self) -> None: """Set up the client. @@ -51,10 +64,10 @@ class AsyncMQTTClient(MQTTClient): since the client is running in an async event loop and will never run in multiple threads. """ - self._in_callback_mutex = NullLock() - self._callback_mutex = NullLock() - self._msgtime_mutex = NullLock() - self._out_message_mutex = NullLock() - self._in_message_mutex = NullLock() - self._reconnect_delay_mutex = NullLock() - self._mid_generate_mutex = NullLock() + self._in_callback_mutex = NullLock() # type: ignore[assignment] + self._callback_mutex = NullLock() # type: ignore[assignment] + self._msgtime_mutex = NullLock() # type: ignore[assignment] + self._out_message_mutex = NullLock() # type: ignore[assignment] + self._in_message_mutex = NullLock() # type: ignore[assignment] + self._reconnect_delay_mutex = NullLock() # type: ignore[assignment] + self._mid_generate_mutex = NullLock() # type: ignore[assignment] diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b49dc7aa24c..a1e146d4e36 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -26,9 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt +from homeassistant.helpers import config_validation as cv, event as evt +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -70,7 +69,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT binary sensor through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 8e5446b532e..5b2bcc8920f 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -9,8 +9,8 @@ from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -43,7 +43,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT button through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 88fabad0446..d3615edcbba 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -60,7 +60,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT camera through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 6500c9f91c9..d35b3db7518 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,7 +15,6 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any -import uuid import certifi @@ -117,7 +116,7 @@ MAX_UNSUBSCRIBES_PER_CALL = 500 MAX_PACKETS_TO_READ = 500 -type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any +type SocketType = socket.socket | ssl.SSLSocket | mqtt._WebsocketWrapper | Any # noqa: SLF001 type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None @@ -220,8 +219,7 @@ def async_subscribe_internal( mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', " - "make sure MQTT is set up correctly", + f"Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly", translation_key="mqtt_not_setup_cannot_subscribe", translation_domain=DOMAIN, translation_placeholders={"topic": topic}, @@ -300,22 +298,39 @@ class MqttClientSetup: from .async_client import AsyncMQTTClient config = self._config + clean_session: bool | None = None if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31: proto = mqtt.MQTTv31 + clean_session = True elif protocol == PROTOCOL_5: proto = mqtt.MQTTv5 else: proto = mqtt.MQTTv311 + clean_session = True if (client_id := config.get(CONF_CLIENT_ID)) is None: # PAHO MQTT relies on the MQTT server to generate random client IDs. # However, that feature is not mandatory so we generate our own. - client_id = mqtt.base62(uuid.uuid4().int, padding=22) + client_id = None transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( - client_id, + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=client_id, + # See: https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html + # clean_session (bool defaults to None) + # a boolean that determines the client type. + # If True, the broker will remove all information about this client when it + # disconnects. If False, the client is a persistent client and subscription + # information and queued messages will be retained when the client + # disconnects. Note that a client will never discard its own outgoing + # messages on disconnect. Calling connect() or reconnect() will cause the + # messages to be resent. Use reinitialise() to reset a client to its + # original state. The clean_session argument only applies to MQTT versions + # v3.1.1 and v3.1. It is not accepted if the MQTT version is v5.0 - use the + # clean_start argument on connect() instead. + clean_session=clean_session, protocol=proto, - transport=transport, + transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, ) self._client.setup() @@ -372,6 +387,7 @@ class MQTT: self.loop = hass.loop self.config_entry = config_entry self.conf = conf + self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5 self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict( set @@ -477,9 +493,9 @@ class MQTT: mqttc.on_connect = self._async_mqtt_on_connect mqttc.on_disconnect = self._async_mqtt_on_disconnect mqttc.on_message = self._async_mqtt_on_message - mqttc.on_publish = self._async_mqtt_on_callback - mqttc.on_subscribe = self._async_mqtt_on_callback - mqttc.on_unsubscribe = self._async_mqtt_on_callback + mqttc.on_publish = self._async_mqtt_on_publish + mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe + mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe # suppress exceptions at callback mqttc.suppress_exceptions = True @@ -499,7 +515,7 @@ class MQTT: def _async_reader_callback(self, client: mqtt.Client) -> None: """Handle reading data from the socket.""" if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) @callback def _async_start_misc_periodic(self) -> None: @@ -534,7 +550,7 @@ class MQTT: try: # Some operating systems do not allow us to set the preferred # buffer size. In that case we try some other size options. - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) # type: ignore[union-attr] except OSError as err: if new_buffer_size <= MIN_BUFFER_SIZE: _LOGGER.warning( @@ -594,7 +610,7 @@ class MQTT: def _async_writer_callback(self, client: mqtt.Client) -> None: """Handle writing data to the socket.""" if (status := client.loop_write()) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) def _on_socket_register_write( self, client: mqtt.Client, userdata: Any, sock: SocketType @@ -653,14 +669,25 @@ class MQTT: result: int | None = None self._available_future = client_available self._should_reconnect = True + connect_partial = partial( + self._mqttc.connect, + host=self.conf[CONF_BROKER], + port=self.conf.get(CONF_PORT, DEFAULT_PORT), + keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), + # See: + # https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html + # `clean_start` (bool) – (MQTT v5.0 only) `True`, `False` or + # `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag + # always, never or on the first successful connect only, + # respectively. MQTT session data (such as outstanding messages and + # subscriptions) is cleared on successful connect when the + # clean_start flag is set. For MQTT v3.1.1, the clean_session + # argument of Client should be used for similar result. + clean_start=True if self.is_mqttv5 else mqtt.MQTT_CLEAN_START_FIRST_ONLY, + ) try: async with self._connection_lock, self._async_connect_in_executor(): - result = await self.hass.async_add_executor_job( - self._mqttc.connect, - self.conf[CONF_BROKER], - self.conf.get(CONF_PORT, DEFAULT_PORT), - self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), - ) + result = await self.hass.async_add_executor_job(connect_partial) except (OSError, mqtt.WebsocketConnectionError) as err: _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) self._async_connection_result(False) @@ -984,9 +1011,9 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, - _flags: dict[str, int], - result_code: int, - properties: mqtt.Properties | None = None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """On connect callback. @@ -994,19 +1021,20 @@ class MQTT: message. """ # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - if result_code != mqtt.CONNACK_ACCEPTED: - if result_code in ( - mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD, - mqtt.CONNACK_REFUSED_NOT_AUTHORIZED, - ): + if reason_code.is_failure: + # 24: Continue authentication + # 25: Re-authenticate + # 134: Bad user name or password + # 135: Not authorized + # 140: Bad authentication method + if reason_code.value in (24, 25, 134, 135, 140): self._should_reconnect = False self.hass.async_create_task(self.async_disconnect()) self.config_entry.async_start_reauth(self.hass) _LOGGER.error( "Unable to connect to the MQTT broker: %s", - mqtt.connack_string(result_code), + reason_code.getName(), # type: ignore[no-untyped-call] ) self._async_connection_result(False) return @@ -1017,7 +1045,7 @@ class MQTT: "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) birth: dict[str, Any] @@ -1154,18 +1182,32 @@ class MQTT: self._mqtt_data.state_write_requests.process_write_state_requests(msg) @callback - def _async_mqtt_on_callback( + def _async_mqtt_on_publish( self, _mqttc: mqtt.Client, _userdata: None, mid: int, - _granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None, - _properties_reason: mqtt.ReasonCodes | None = None, + _reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None, ) -> None: + """Publish callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_subscribe_unsubscribe( + self, + _mqttc: mqtt.Client, + _userdata: None, + mid: int, + _reason_code: list[mqtt.ReasonCode], + _properties: mqtt.Properties | None, + ) -> None: + """Subscribe / Unsubscribe callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_callback(self, mid: int) -> None: """Publish / Subscribe / Unsubscribe callback.""" - # The callback signature for on_unsubscribe is different from on_subscribe - # see https://github.com/eclipse/paho.mqtt.python/issues/687 - # properties and reason codes are not used in Home Assistant future = self._async_get_mid_future(mid) if future.done() and (future.cancelled() or future.exception()): # Timed out or cancelled @@ -1181,19 +1223,28 @@ class MQTT: self._pending_operations[mid] = future return future + @callback + def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None: + """Handle a callback exception.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + _LOGGER.warning( + "Error returned from MQTT server: %s", + mqtt.error_string(status), + ) + @callback def _async_mqtt_on_disconnect( self, _mqttc: mqtt.Client, _userdata: None, - result_code: int, + _disconnect_flags: mqtt.DisconnectFlags, + reason_code: mqtt.ReasonCode, properties: mqtt.Properties | None = None, ) -> None: """Disconnected callback.""" - self._async_on_disconnect(result_code) - - @callback - def _async_on_disconnect(self, result_code: int) -> None: if not self.connected: # This function is re-entrant and may be called multiple times # when there is a broken pipe error. @@ -1204,11 +1255,11 @@ class MQTT: self.connected = False async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False) _LOGGER.log( - logging.INFO if result_code == 0 else logging.DEBUG, + logging.INFO if reason_code == 0 else logging.DEBUG, "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) @callback @@ -1217,7 +1268,9 @@ class MQTT: if not future.done(): future.set_exception(asyncio.TimeoutError) - async def _async_wait_for_mid_or_raise(self, mid: int, result_code: int) -> None: + async def _async_wait_for_mid_or_raise( + self, mid: int | None, result_code: int + ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: # pylint: disable-next=import-outside-toplevel @@ -1233,6 +1286,8 @@ class MQTT: # Create the mid event if not created, either _mqtt_handle_mid or # _async_wait_for_mid_or_raise may be executed first. + if TYPE_CHECKING: + assert mid is not None future = self._async_get_mid_future(mid) loop = self.hass.loop timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future) @@ -1270,7 +1325,7 @@ def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: # pylint: disable-next=import-outside-toplevel from paho.mqtt.matcher import MQTTMatcher - matcher = MQTTMatcher() + matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True - return lambda topic: next(matcher.iter_match(topic), False) + return lambda topic: next(matcher.iter_match(topic), False) # type: ignore[no-untyped-call] diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index e62303472ed..931a57a71cc 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,8 +44,8 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -113,11 +113,19 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" CONF_PRESET_MODES_LIST = "preset_modes" + +CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" +CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" +CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" +CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" + CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" + CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -145,6 +153,8 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( climate.ATTR_MIN_TEMP, climate.ATTR_PRESET_MODE, climate.ATTR_PRESET_MODES, + climate.ATTR_SWING_HORIZONTAL_MODE, + climate.ATTR_SWING_HORIZONTAL_MODES, climate.ATTR_SWING_MODE, climate.ATTR_SWING_MODES, climate.ATTR_TARGET_TEMP_HIGH, @@ -162,6 +172,7 @@ VALUE_TEMPLATE_KEYS = ( CONF_MODE_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, CONF_TEMP_HIGH_STATE_TEMPLATE, CONF_TEMP_LOW_STATE_TEMPLATE, @@ -174,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_MODE_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_HIGH_COMMAND_TEMPLATE, @@ -194,6 +206,8 @@ TOPIC_KEYS = ( CONF_POWER_COMMAND_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TOPIC, @@ -302,6 +316,13 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF] + ): cv.ensure_list, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( @@ -350,7 +371,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT climate through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( @@ -515,6 +536,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attr_fan_mode: str | None = None _attr_hvac_mode: HVACMode | None = None + _attr_swing_horizontal_mode: str | None = None _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT @@ -543,6 +565,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if (precision := config.get(CONF_PRECISION)) is not None: self._attr_precision = precision self._attr_fan_modes = config[CONF_FAN_MODE_LIST] + self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST] self._attr_swing_modes = config[CONF_SWING_MODE_LIST] self._attr_target_temperature_step = config[CONF_TEMP_STEP] @@ -568,6 +591,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_fan_mode = FAN_LOW + if ( + self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + or self._optimistic + ): + self._attr_swing_horizontal_mode = SWING_OFF if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: @@ -629,6 +657,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ): support |= ClimateEntityFeature.FAN_MODE + if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None + ): + support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or ( self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None ): @@ -744,6 +777,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ), {"_attr_fan_mode"}, ) + self.add_subscription( + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + "_attr_swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE_LIST, + ), + {"_attr_swing_horizontal_mode"}, + ) self.add_subscription( CONF_SWING_MODE_STATE_TOPIC, partial( @@ -782,6 +825,20 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self.async_write_ha_state() + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE]( + swing_horizontal_mode + ) + await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload) + + if ( + self._optimistic + or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + ): + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 0081246c705..ad188c50aa9 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,14 +5,21 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType from typing import TYPE_CHECKING, Any -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + load_der_private_key, + load_pem_private_key, +) +from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file @@ -76,6 +83,8 @@ from .const import ( CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, DEFAULT_DISCOVERY, DEFAULT_ENCODING, @@ -103,6 +112,8 @@ _LOGGER = logging.getLogger(__name__) ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 5 +CONF_CLIENT_KEY_PASSWORD = "client_key_password" + MQTT_TIMEOUT = 5 ADVANCED_OPTIONS = "advanced_options" @@ -163,12 +174,14 @@ BROKER_VERIFICATION_SELECTOR = SelectSelector( # mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html CA_CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-ca-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert") ) CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-user-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert") +) +KEY_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") ) -KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) REAUTH_SCHEMA = vol.Schema( { @@ -205,7 +218,9 @@ def update_password_from_user_input( class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + # Can be bumped to version 2.1 with HA Core 2026.1.0 + VERSION = CONFIG_ENTRY_VERSION # 1 + MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2 _hassio_discovery: dict[str, Any] | None = None _addon_manager: AddonManager @@ -481,7 +496,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors, ): if is_reconfigure: - update_password_from_user_input( + validated_user_input = update_password_from_user_input( reconfigure_entry.data.get(CONF_PASSWORD), validated_user_input ) @@ -496,7 +511,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): reconfigure_entry, data=validated_user_input, ) - validated_user_input[CONF_DISCOVERY] = DEFAULT_DISCOVERY return self.async_create_entry( title=validated_user_input[CONF_BROKER], data=validated_user_input, @@ -564,58 +578,17 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" - def __init__(self) -> None: - """Initialize MQTT options flow.""" - self.broker_config: dict[str, Any] = {} - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the MQTT options.""" - return await self.async_step_broker() - - async def async_step_broker( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the MQTT broker configuration.""" - errors: dict[str, str] = {} - fields: OrderedDict[Any, Any] = OrderedDict() - validated_user_input: dict[str, Any] = {} - if await async_get_broker_settings( - self, - fields, - self.config_entry.data, - user_input, - validated_user_input, - errors, - ): - self.broker_config.update( - update_password_from_user_input( - self.config_entry.data.get(CONF_PASSWORD), validated_user_input - ), - ) - can_connect = await self.hass.async_add_executor_job( - try_connection, - self.broker_config, - ) - - if can_connect: - return await self.async_step_options() - - errors["base"] = "cannot_connect" - - return self.async_show_form( - step_id="broker", - data_schema=vol.Schema(fields), - errors=errors, - last_step=False, - ) + return await self.async_step_options() async def async_step_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the MQTT options.""" errors = {} - current_config = self.config_entry.data - options_config: dict[str, Any] = {} + + options_config: dict[str, Any] = dict(self.config_entry.options) bad_input: bool = False def _birth_will(birt_or_will: str) -> dict[str, Any]: @@ -674,26 +647,18 @@ class MQTTOptionsFlowHandler(OptionsFlow): options_config[CONF_WILL_MESSAGE] = {} if not bad_input: - updated_config = {} - updated_config.update(self.broker_config) - updated_config.update(options_config) - self.hass.config_entries.async_update_entry( - self.config_entry, - data=updated_config, - title=str(self.broker_config[CONF_BROKER]), - ) - return self.async_create_entry(title="", data={}) + return self.async_create_entry(data=options_config) birth = { **DEFAULT_BIRTH, - **current_config.get(CONF_BIRTH_MESSAGE, {}), + **options_config.get(CONF_BIRTH_MESSAGE, {}), } will = { **DEFAULT_WILL, - **current_config.get(CONF_WILL_MESSAGE, {}), + **options_config.get(CONF_WILL_MESSAGE, {}), } - discovery = current_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) - discovery_prefix = current_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) + discovery = options_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY) + discovery_prefix = options_config.get(CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX) # build form fields: OrderedDict[vol.Marker, Any] = OrderedDict() @@ -706,8 +671,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[ vol.Optional( "birth_enable", - default=CONF_BIRTH_MESSAGE not in current_config - or current_config[CONF_BIRTH_MESSAGE] != {}, + default=CONF_BIRTH_MESSAGE not in options_config + or options_config[CONF_BIRTH_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -729,8 +694,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): fields[ vol.Optional( "will_enable", - default=CONF_WILL_MESSAGE not in current_config - or current_config[CONF_WILL_MESSAGE] != {}, + default=CONF_WILL_MESSAGE not in options_config + or options_config[CONF_WILL_MESSAGE] != {}, ) ] = BOOLEAN_SELECTOR fields[ @@ -756,17 +721,88 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) -async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: - """Get file content from uploaded file.""" +@callback +def async_is_pem_data(data: bytes) -> bool: + """Return True if data is in PEM format.""" + return ( + b"-----BEGIN CERTIFICATE-----" in data + or b"-----BEGIN PRIVATE KEY-----" in data + or b"-----BEGIN RSA PRIVATE KEY-----" in data + or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data + ) - def _proces_uploaded_file() -> str: + +class PEMType(IntEnum): + """Type of PEM data.""" + + CERTIFICATE = 1 + PRIVATE_KEY = 2 + + +@callback +def async_convert_to_pem( + data: bytes, pem_type: PEMType, password: str | None = None +) -> str | None: + """Convert data to PEM format.""" + try: + if async_is_pem_data(data): + if not password: + # Assume unencrypted PEM encoded private key + return data.decode(DEFAULT_ENCODING) + # Return decrypted PEM encoded private key + return ( + load_pem_private_key(data, password=password.encode(DEFAULT_ENCODING)) + .private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + .decode(DEFAULT_ENCODING) + ) + # Convert from DER encoding to PEM + if pem_type == PEMType.CERTIFICATE: + return ( + load_der_x509_certificate(data) + .public_bytes( + encoding=Encoding.PEM, + ) + .decode(DEFAULT_ENCODING) + ) + # Assume DER encoded private key + pem_key_data: bytes = load_der_private_key( + data, password.encode(DEFAULT_ENCODING) if password else None + ).private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + return pem_key_data.decode("utf-8") + except (TypeError, ValueError, SSLError): + _LOGGER.exception("Error converting %s file data to PEM format", pem_type.name) + return None + + +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes: + """Get file content from uploaded certificate or key file.""" + + def _proces_uploaded_file() -> bytes: with process_uploaded_file(hass, id) as file_path: - return file_path.read_text(encoding=DEFAULT_ENCODING) + return file_path.read_bytes() return await hass.async_add_executor_job(_proces_uploaded_file) -async def async_get_broker_settings( +def _validate_pki_file( + file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str +) -> bool: + """Return False if uploaded file could not be converted to PEM format.""" + if file_id and not pem_data: + errors["base"] = error + return False + return True + + +async def async_get_broker_settings( # noqa: C901 flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, @@ -814,35 +850,66 @@ async def async_get_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) - if ( - client_certificate_id - and not client_key_id - or not client_certificate_id - and client_key_id + # We do not store the private key password in the entry data + client_key_password: str | None = validated_user_input.pop( + CONF_CLIENT_KEY_PASSWORD, None + ) + if (client_certificate_id and not client_key_id) or ( + not client_certificate_id and client_key_id ): errors["base"] = "invalid_inclusion" return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - certificate = await _get_uploaded_file(hass, certificate_id) + certificate_data_raw = await _get_uploaded_file(hass, certificate_id) + certificate = async_convert_to_pem( + certificate_data_raw, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + certificate_id, certificate, errors, "bad_certificate" + ): + return False # Return to form for file upload CA cert or client cert and key if ( - not client_certificate - and user_input.get(SET_CLIENT_CERT) - and not client_certificate_id - or not certificate - and user_input.get(SET_CA_CERT, "off") == "custom" - and not certificate_id - or user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS - and CONF_WS_PATH not in user_input + ( + not client_certificate + and user_input.get(SET_CLIENT_CERT) + and not client_certificate_id + ) + or ( + not certificate + and user_input.get(SET_CA_CERT, "off") == "custom" + and not certificate_id + ) + or ( + user_input.get(CONF_TRANSPORT) == TRANSPORT_WEBSOCKETS + and CONF_WS_PATH not in user_input + ) ): return False if client_certificate_id: - client_certificate = await _get_uploaded_file(hass, client_certificate_id) + client_certificate_data = await _get_uploaded_file( + hass, client_certificate_id + ) + client_certificate = async_convert_to_pem( + client_certificate_data, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + client_certificate_id, client_certificate, errors, "bad_client_cert" + ): + return False + if client_key_id: - client_key = await _get_uploaded_file(hass, client_key_id) + client_key_data = await _get_uploaded_file(hass, client_key_id) + client_key = async_convert_to_pem( + client_key_data, PEMType.PRIVATE_KEY, password=client_key_password + ) + if not _validate_pki_file( + client_key_id, client_key, errors, "client_key_error" + ): + return False certificate_data: dict[str, Any] = {} if certificate: @@ -999,6 +1066,14 @@ async def async_get_broker_settings( description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)}, ) ] = KEY_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_CLIENT_KEY_PASSWORD, + description={ + "suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD) + }, + ) + ] = PASSWORD_SELECTOR verification_mode = current_config.get(SET_CA_CERT) or ( "off" if current_ca_certificate is None @@ -1066,14 +1141,14 @@ def try_connection( result: queue.Queue[bool] = queue.Queue(maxsize=1) def on_connect( - client_: mqtt.Client, - userdata: None, - flags: dict[str, Any], - result_code: int, - properties: mqtt.Properties | None = None, + _mqttc: mqtt.Client, + _userdata: None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """Handle connection result.""" - result.put(result_code == mqtt.CONNACK_ACCEPTED) + result.put(not reason_code.is_failure) client.on_connect = on_connect @@ -1103,7 +1178,7 @@ def check_certicate_chain() -> str | None: with open(private_key, "rb") as client_key_file: load_pem_private_key(client_key_file.read(), password=None) except (TypeError, ValueError): - return "bad_client_key" + return "client_key_error" # Check the certificate chain context = SSLContext(PROTOCOL_TLS_CLIENT) if client_certificate and private_key: diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 9f1c55a54e0..007b3b7e576 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,7 +4,7 @@ import logging import jinja2 -from homeassistant.const import CONF_PAYLOAD, Platform +from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" @@ -56,12 +56,15 @@ CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_ENTITY_PICTURE = "entity_picture" +CONF_MAX_KELVIN = "max_kelvin" +CONF_MIN_KELVIN = "min_kelvin" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" @@ -160,6 +163,20 @@ MQTT_CONNECTION_STATE = "mqtt_connection_state" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" +CONFIG_ENTRY_VERSION = 1 +CONFIG_ENTRY_MINOR_VERSION = 2 + +# Split mqtt entry data and options +# Can be removed when config entry is bumped to version 2.1 +# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1 +# From 2026.1 we will write version 2.1 +ENTRY_OPTION_FIELDS = ( + CONF_DISCOVERY, + CONF_DISCOVERY_PREFIX, + "birth_message", + "will_message", +) + ENTITY_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c7d041848f0..c93fdd9c760 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -29,8 +29,8 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -220,7 +220,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT cover through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index bdf543e046a..4017245cf51 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -21,8 +21,8 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -79,7 +79,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 8104c37574b..7a17c1f3409 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -18,7 +18,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected -from .models import DATA_MQTT REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} @@ -45,11 +44,10 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - mqtt_instance = hass.data[DATA_MQTT].client - if TYPE_CHECKING: - assert mqtt_instance is not None - - redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) + redacted_config = { + "data": async_redact_data(dict(entry.data), REDACT_CONFIG), + "options": dict(entry.options), + } data = { "connected": is_connected(hass), diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a5ddb3ef4e6..a14240ce008 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HassJobType, HomeAssistant, callback -from homeassistant.helpers import discovery_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -138,7 +137,10 @@ def get_origin_log_string( support_url_log = "" if include_url and (support_url := get_origin_support_url(discovery_payload)): support_url_log = f", support URL: {support_url}" - return f" from external application {origin_info["name"]}{sw_version_log}{support_url_log}" + return ( + " from external application " + f"{origin_info['name']}{sw_version_log}{support_url_log}" + ) @callback @@ -383,7 +385,7 @@ async def async_start( # noqa: C901 _async_add_component(discovery_payload) @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 + def async_discovery_message_received(msg: ReceiveMessage) -> None: """Process the received message.""" mqtt_data.last_discovery = msg.timestamp payload = msg.payload diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index d9812aaaf48..aef21838d59 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -17,8 +17,8 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object @@ -73,7 +73,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( @@ -151,7 +151,7 @@ class MqttEvent(MqttEntity, EventEntity): ) except KeyError: _LOGGER.warning( - ("`event_type` missing in JSON event payload, " " '%s' on topic %s"), + "`event_type` missing in JSON event payload, '%s' on topic %s", payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4d2e764a0d5..3fac4d4ffe0 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -27,8 +27,8 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -190,7 +190,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT fan through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 5d1af03ad24..07ddcddb13a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -30,8 +30,8 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -183,7 +183,7 @@ TOPICS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT humidifier through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 4b7b2d783d2..a668608dd55 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType @@ -82,7 +82,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT image through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 87577c4b4d9..1917c56f209 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import lawn_mower from homeassistant.components.lawn_mower import ( + ENTITY_ID_FORMAT, LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, @@ -18,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" DEFAULT_NAME = "MQTT Lawn Mower" -ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() @@ -80,7 +80,7 @@ DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EX async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT lawn mower through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 328f80cb5ea..3ffad9226be 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import light from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from ..entity import async_setup_entity_entry_helper @@ -69,7 +69,7 @@ PLATFORM_SCHEMA_MODERN = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT lights through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 159a23d14d9..a2f424b247d 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -42,16 +42,19 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -182,6 +185,7 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), @@ -193,6 +197,8 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In( VALUES_ON_COMMAND_TYPE @@ -239,6 +245,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED _topic: dict[str, str | None] _payload: dict[str, str] + _color_temp_kelvin: bool _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] @@ -263,16 +270,18 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) + self._attr_effect_list = config.get(CONF_EFFECT_LIST) topic: dict[str, str | None] = { @@ -526,6 +535,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP + if self._color_temp_kelvin: + self._attr_color_temp_kelvin = int(payload) + return self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( int(payload) ) @@ -575,7 +587,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_xy_color = cast(tuple[float, float], xy_color) @callback - def _prepare_subscribe_topics(self) -> None: # noqa: C901 + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) self.add_subscription( @@ -818,7 +830,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): ): ct_command_tpl = self._command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE] color_temp = ct_command_tpl( - color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ), None, diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index f6efdd3281d..4473385d550 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from contextlib import suppress import logging from typing import TYPE_CHECKING, Any, cast @@ -24,7 +23,6 @@ from homeassistant.components.light import ( ATTR_XY_COLOR, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, - DOMAIN as LIGHT_DOMAIN, ENTITY_ID_FORMAT, FLASH_LONG, FLASH_SHORT, @@ -34,7 +32,6 @@ from homeassistant.components.light import ( LightEntityFeature, brightness_supported, color_supported, - filter_supported_color_modes, valid_supported_color_modes, ) from homeassistant.const import ( @@ -48,24 +45,24 @@ from homeassistant.const import ( CONF_XY, STATE_ON, ) -from homeassistant.core import async_get_hass, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from homeassistant.util.json import json_loads_object -from homeassistant.util.yaml import dump as yaml_dump from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - DOMAIN as MQTT_DOMAIN, ) from ..entity import MqttEntity from ..models import ReceiveMessage @@ -83,15 +80,10 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "mqtt_json" DEFAULT_BRIGHTNESS = False -DEFAULT_COLOR_MODE = False -DEFAULT_COLOR_TEMP = False DEFAULT_EFFECT = False DEFAULT_FLASH_TIME_LONG = 10 DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_NAME = "MQTT JSON Light" -DEFAULT_RGB = False -DEFAULT_XY = False -DEFAULT_HS = False DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_WHITE_SCALE = 255 @@ -107,89 +99,6 @@ CONF_MAX_MIREDS = "max_mireds" CONF_MIN_MIREDS = "min_mireds" -def valid_color_configuration( - setup_from_yaml: bool, -) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Test color_mode is not combined with deprecated config.""" - - def _valid_color_configuration(config: ConfigType) -> ConfigType: - deprecated = {CONF_COLOR_TEMP, CONF_HS, CONF_RGB, CONF_XY} - deprecated_flags_used = any(config.get(key) for key in deprecated) - if config.get(CONF_SUPPORTED_COLOR_MODES): - if deprecated_flags_used: - raise vol.Invalid( - "supported_color_modes must not " - f"be combined with any of {deprecated}" - ) - elif deprecated_flags_used: - deprecated_flags = ", ".join(key for key in deprecated if key in config) - _LOGGER.warning( - "Deprecated flags [%s] used in MQTT JSON light config " - "for handling color mode, please use `supported_color_modes` instead. " - "Got: %s. This will stop working in Home Assistant Core 2025.3", - deprecated_flags, - config, - ) - if not setup_from_yaml: - return config - issue_id = hex(hash(frozenset(config))) - yaml_config_str = yaml_dump(config) - learn_more_url = ( - "https://www.home-assistant.io/integrations/" - f"{LIGHT_DOMAIN}.mqtt/#json-schema" - ) - hass = async_get_hass() - async_create_issue( - hass, - MQTT_DOMAIN, - issue_id, - issue_domain=LIGHT_DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=learn_more_url, - translation_placeholders={ - "deprecated_flags": deprecated_flags, - "config": yaml_config_str, - }, - translation_key="deprecated_color_handling", - ) - - if CONF_COLOR_MODE in config: - _LOGGER.warning( - "Deprecated flag `color_mode` used in MQTT JSON light config " - ", the `color_mode` flag is not used anymore and should be removed. " - "Got: %s. This will stop working in Home Assistant Core 2025.3", - config, - ) - if not setup_from_yaml: - return config - issue_id = hex(hash(frozenset(config))) - yaml_config_str = yaml_dump(config) - learn_more_url = ( - "https://www.home-assistant.io/integrations/" - f"{LIGHT_DOMAIN}.mqtt/#json-schema" - ) - hass = async_get_hass() - async_create_issue( - hass, - MQTT_DOMAIN, - issue_id, - breaks_in_ha_version="2025.3.0", - issue_domain=LIGHT_DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=learn_more_url, - translation_placeholders={ - "config": yaml_config_str, - }, - translation_key="deprecated_color_mode_flag", - ) - - return config - - return _valid_color_configuration - - _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( { @@ -197,12 +106,7 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional( CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE ): vol.All(vol.Coerce(int), vol.Range(min=1)), - # CONF_COLOR_MODE was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_COLOR_MODE): cv.boolean, - # CONF_COLOR_TEMP was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional( @@ -211,19 +115,15 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional( CONF_FLASH_TIME_SHORT, default=DEFAULT_FLASH_TIME_SHORT ): cv.positive_int, - # CONF_HS was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) ), vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - # CONF_RGB was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_RGB, default=DEFAULT_RGB): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SUPPORTED_COLOR_MODES): vol.All( cv.ensure_list, @@ -234,22 +134,29 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Coerce(int), vol.Range(min=1) ), - # CONF_XY was deprecated with HA Core 2024.4 and will be - # removed with HA Core 2025.3 - vol.Optional(CONF_XY, default=DEFAULT_XY): cv.boolean, }, ) .extend(MQTT_ENTITY_COMMON_SCHEMA.schema) .extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) ) +# Support for legacy color_mode handling was removed with HA Core 2025.3 +# The removed attributes can be removed from the schema's from HA Core 2026.3 DISCOVERY_SCHEMA_JSON = vol.All( - valid_color_configuration(False), + cv.removed(CONF_COLOR_MODE, raise_if_present=False), + cv.removed(CONF_COLOR_TEMP, raise_if_present=False), + cv.removed(CONF_HS, raise_if_present=False), + cv.removed(CONF_RGB, raise_if_present=False), + cv.removed(CONF_XY, raise_if_present=False), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) PLATFORM_SCHEMA_MODERN_JSON = vol.All( - valid_color_configuration(True), + cv.removed(CONF_COLOR_MODE), + cv.removed(CONF_COLOR_TEMP), + cv.removed(CONF_HS), + cv.removed(CONF_RGB), + cv.removed(CONF_XY), _PLATFORM_SCHEMA_BASE, ) @@ -266,8 +173,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _topic: dict[str, str | None] _optimistic: bool - _deprecated_color_handling: bool = False - @staticmethod def config_schema() -> VolSchemaType: """Return the config schema.""" @@ -275,15 +180,16 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) @@ -311,120 +217,69 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN - else: - self._deprecated_color_handling = True - color_modes = {ColorMode.ONOFF} - if config[CONF_BRIGHTNESS]: - color_modes.add(ColorMode.BRIGHTNESS) - if config[CONF_COLOR_TEMP]: - color_modes.add(ColorMode.COLOR_TEMP) - if config[CONF_HS] or config[CONF_RGB] or config[CONF_XY]: - color_modes.add(ColorMode.HS) - self._attr_supported_color_modes = filter_supported_color_modes(color_modes) - if self.supported_color_modes and len(self.supported_color_modes) == 1: - self._fixed_color_mode = next(iter(self.supported_color_modes)) + elif config.get(CONF_BRIGHTNESS): + # Brightness is supported and no supported_color_modes are set, + # so set brightness as the supported color mode. + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def _update_color(self, values: dict[str, Any]) -> None: - if self._deprecated_color_handling: - # Deprecated color handling - try: - red = int(values["color"]["r"]) - green = int(values["color"]["g"]) - blue = int(values["color"]["b"]) - self._attr_hs_color = color_util.color_RGB_to_hs(red, green, blue) - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid RGB color value '%s' received for entity %s", - values, - self.entity_id, + color_mode: str = values["color_mode"] + if not self._supports_color_mode(color_mode): + _LOGGER.warning( + "Invalid color mode '%s' received for entity %s", + color_mode, + self.entity_id, + ) + return + try: + if color_mode == ColorMode.COLOR_TEMP: + self._attr_color_temp_kelvin = ( + values["color_temp"] + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + values["color_temp"] + ) ) - return - - try: - x_color = float(values["color"]["x"]) - y_color = float(values["color"]["y"]) - self._attr_hs_color = color_util.color_xy_to_hs(x_color, y_color) - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid XY color value '%s' received for entity %s", - values, - self.entity_id, - ) - return - - try: + self._attr_color_mode = ColorMode.COLOR_TEMP + elif color_mode == ColorMode.HS: hue = float(values["color"]["h"]) saturation = float(values["color"]["s"]) + self._attr_color_mode = ColorMode.HS self._attr_hs_color = (hue, saturation) - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid HS color value '%s' received for entity %s", - values, - self.entity_id, - ) - return - else: - color_mode: str = values["color_mode"] - if not self._supports_color_mode(color_mode): - _LOGGER.warning( - "Invalid color mode '%s' received for entity %s", - color_mode, - self.entity_id, - ) - return - try: - if color_mode == ColorMode.COLOR_TEMP: - self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( - values["color_temp"] - ) - ) - self._attr_color_mode = ColorMode.COLOR_TEMP - elif color_mode == ColorMode.HS: - hue = float(values["color"]["h"]) - saturation = float(values["color"]["s"]) - self._attr_color_mode = ColorMode.HS - self._attr_hs_color = (hue, saturation) - elif color_mode == ColorMode.RGB: - r = int(values["color"]["r"]) - g = int(values["color"]["g"]) - b = int(values["color"]["b"]) - self._attr_color_mode = ColorMode.RGB - self._attr_rgb_color = (r, g, b) - elif color_mode == ColorMode.RGBW: - r = int(values["color"]["r"]) - g = int(values["color"]["g"]) - b = int(values["color"]["b"]) - w = int(values["color"]["w"]) - self._attr_color_mode = ColorMode.RGBW - self._attr_rgbw_color = (r, g, b, w) - elif color_mode == ColorMode.RGBWW: - r = int(values["color"]["r"]) - g = int(values["color"]["g"]) - b = int(values["color"]["b"]) - c = int(values["color"]["c"]) - w = int(values["color"]["w"]) - self._attr_color_mode = ColorMode.RGBWW - self._attr_rgbww_color = (r, g, b, c, w) - elif color_mode == ColorMode.WHITE: - self._attr_color_mode = ColorMode.WHITE - elif color_mode == ColorMode.XY: - x = float(values["color"]["x"]) - y = float(values["color"]["y"]) - self._attr_color_mode = ColorMode.XY - self._attr_xy_color = (x, y) - except (KeyError, ValueError): - _LOGGER.warning( - "Invalid or incomplete color value '%s' received for entity %s", - values, - self.entity_id, - ) + elif color_mode == ColorMode.RGB: + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + self._attr_color_mode = ColorMode.RGB + self._attr_rgb_color = (r, g, b) + elif color_mode == ColorMode.RGBW: + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + w = int(values["color"]["w"]) + self._attr_color_mode = ColorMode.RGBW + self._attr_rgbw_color = (r, g, b, w) + elif color_mode == ColorMode.RGBWW: + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + c = int(values["color"]["c"]) + w = int(values["color"]["w"]) + self._attr_color_mode = ColorMode.RGBWW + self._attr_rgbww_color = (r, g, b, c, w) + elif color_mode == ColorMode.WHITE: + self._attr_color_mode = ColorMode.WHITE + elif color_mode == ColorMode.XY: + x = float(values["color"]["x"]) + y = float(values["color"]["y"]) + self._attr_color_mode = ColorMode.XY + self._attr_xy_color = (x, y) + except (KeyError, TypeError, ValueError): + _LOGGER.warning( + "Invalid or incomplete color value '%s' received for entity %s", + values, + self.entity_id, + ) @callback def _state_received(self, msg: ReceiveMessage) -> None: @@ -438,18 +293,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): elif values["state"] is None: self._attr_is_on = None - if ( - self._deprecated_color_handling - and color_supported(self.supported_color_modes) - and "color" in values - ): - # Deprecated color handling - if values["color"] is None: - self._attr_hs_color = None - else: - self._update_color(values) - - if not self._deprecated_color_handling and "color_mode" in values: + if color_supported(self.supported_color_modes) and "color_mode" in values: self._update_color(values) if brightness_supported(self.supported_color_modes): @@ -475,33 +319,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self.entity_id, ) - if ( - self._deprecated_color_handling - and self.supported_color_modes - and ColorMode.COLOR_TEMP in self.supported_color_modes - ): - # Deprecated color handling - try: - if values["color_temp"] is None: - self._attr_color_temp_kelvin = None - else: - self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( - values["color_temp"] # type: ignore[arg-type] - ) - ) - except KeyError: - pass - except (TypeError, ValueError): - _LOGGER.warning( - "Invalid color temp value '%s' received for entity %s", - values["color_temp"], - self.entity_id, - ) - # Allow to switch back to color_temp - if "color" not in values: - self._attr_hs_color = None - if self.supported_features and LightEntityFeature.EFFECT: with suppress(KeyError): self._attr_effect = cast(str, values["effect"]) @@ -554,19 +371,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): ) self._attr_xy_color = last_attributes.get(ATTR_XY_COLOR, self.xy_color) - @property - def color_mode(self) -> ColorMode | str | None: - """Return current color mode.""" - if not self._deprecated_color_handling: - return self._attr_color_mode - if self._fixed_color_mode: - # Legacy light with support for a single color mode - return self._fixed_color_mode - # Legacy light with support for ct + hs, prioritize hs - if self.hs_color is not None: - return ColorMode.HS - return ColorMode.COLOR_TEMP - def _set_flash_and_transition(self, message: dict[str, Any], **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: message["transition"] = kwargs[ATTR_TRANSITION] @@ -593,17 +397,15 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): def _supports_color_mode(self, color_mode: ColorMode | str) -> bool: """Return True if the light natively supports a color mode.""" return ( - not self._deprecated_color_handling - and self.supported_color_modes is not None + self.supported_color_modes is not None and color_mode in self.supported_color_modes ) - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on. This method is a coroutine. """ - brightness: int should_update = False hs_color: tuple[float, float] message: dict[str, Any] = {"state": "ON"} @@ -612,39 +414,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): rgbcw: tuple[int, ...] xy_color: tuple[float, float] - if ATTR_HS_COLOR in kwargs and ( - self._config[CONF_HS] or self._config[CONF_RGB] or self._config[CONF_XY] - ): - # Legacy color handling - hs_color = kwargs[ATTR_HS_COLOR] - message["color"] = {} - if self._config[CONF_RGB]: - # If brightness is supported, we don't want to scale the - # RGB values given using the brightness. - if self._config[CONF_BRIGHTNESS]: - brightness = 255 - else: - # We pop the brightness, to omit it from the payload - brightness = kwargs.pop(ATTR_BRIGHTNESS, 255) - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness / 255 * 100 - ) - message["color"]["r"] = rgb[0] - message["color"]["g"] = rgb[1] - message["color"]["b"] = rgb[2] - if self._config[CONF_XY]: - xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) - message["color"]["x"] = xy_color[0] - message["color"]["y"] = xy_color[1] - if self._config[CONF_HS]: - message["color"]["h"] = hs_color[0] - message["color"]["s"] = hs_color[1] - - if self._optimistic: - self._attr_color_temp_kelvin = None - self._attr_hs_color = kwargs[ATTR_HS_COLOR] - should_update = True - if ATTR_HS_COLOR in kwargs and self._supports_color_mode(ColorMode.HS): hs_color = kwargs[ATTR_HS_COLOR] message["color"] = {"h": hs_color[0], "s": hs_color[1]} @@ -709,10 +478,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): should_update = True if ATTR_COLOR_TEMP_KELVIN in kwargs: - message["color_temp"] = color_util.color_temperature_kelvin_to_mired( + message["color_temp"] = ( kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ) - if self._optimistic: self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 722bd864366..901cee6f14c 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -31,15 +31,22 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA -from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE +from ..const import ( + CONF_COLOR_TEMP_KELVIN, + CONF_COMMAND_TOPIC, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, @@ -85,12 +92,15 @@ PLATFORM_SCHEMA_MODERN_TEMPLATE = ( { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, + vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EFFECT_TEMPLATE): cv.template, vol.Optional(CONF_GREEN_TEMPLATE): cv.template, + vol.Optional(CONF_MAX_KELVIN): cv.positive_int, + vol.Optional(CONF_MIN_KELVIN): cv.positive_int, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, vol.Optional(CONF_NAME): vol.Any(cv.string, None), @@ -128,15 +138,16 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN] self._attr_min_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(max_mireds) if (max_mireds := config.get(CONF_MAX_MIREDS)) - else DEFAULT_MIN_KELVIN + else config.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = ( color_util.color_temperature_mired_to_kelvin(min_mireds) if (min_mireds := config.get(CONF_MIN_MIREDS)) - else DEFAULT_MAX_KELVIN + else config.get(CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._attr_effect_list = config.get(CONF_EFFECT_LIST) @@ -224,7 +235,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): msg.payload ) self._attr_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin(int(color_temp)) + int(color_temp) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin(int(color_temp)) if color_temp != "None" else None ) @@ -310,8 +323,12 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP_KELVIN in kwargs: - values["color_temp"] = color_util.color_temperature_kelvin_to_mired( + values["color_temp"] = ( kwargs[ATTR_COLOR_TEMP_KELVIN] + if self._color_temp_kelvin + else color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) ) if self._optimistic: diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 2113dbbd5ba..727e689798e 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -19,8 +19,8 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -116,7 +116,7 @@ STATE_CONFIG_KEYS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT lock through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 081449b142a..1cd6ae3e47c 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -7,6 +7,7 @@ "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", "iot_class": "local_push", - "requirements": ["paho-mqtt==1.6.1"], + "quality_scale": "platinum", + "requirements": ["paho-mqtt==2.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 84442e75e73..0b6dbce38b4 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -9,8 +9,8 @@ from homeassistant.components.notify import NotifyEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -39,7 +39,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT notify through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a9bf1829b63..5ee93cfba07 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -109,7 +109,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT number through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( @@ -179,14 +179,14 @@ class MqttNumber(MqttEntity, RestoreNumber): return if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value + num_value < self.native_min_value or num_value > self.native_max_value ): _LOGGER.error( "Invalid value for %s: %s (range %s - %s)", self.entity_id, num_value, - self.min_value, - self.max_value, + self.native_min_value, + self.native_max_value, ) return diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml index 26ce8cb08dd..b17812acd91 100644 --- a/homeassistant/components/mqtt/quality_scale.yaml +++ b/homeassistant/components/mqtt/quality_scale.yaml @@ -89,10 +89,7 @@ rules: comment: > This is not possible because the integrations generates entities based on a user supplied config or discovery. - reconfiguration-flow: - status: done - comment: > - This integration can also be reconfigured via options flow. + reconfiguration-flow: done dynamic-devices: status: done comment: | @@ -126,6 +123,7 @@ rules: comment: | This integration does not use web sessions. strict-typing: - status: todo - comment: | - Requirement 'paho-mqtt==1.6.1' appears untyped + status: done + comment: > + Typing for 'paho-mqtt==1.6.1' supported via 'types-paho-mqtt==1.6.0.20240321' + (requirements_test.txt). diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 314bd716ee0..12f680b6e12 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -11,8 +11,8 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA @@ -43,7 +43,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT scene through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 55d56ecd774..1b3ea1a7c44 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -63,7 +63,7 @@ DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EX async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT select through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index bacbf4d323e..3e8a4fef0fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -30,8 +30,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -124,7 +124,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index c5e4f372bd6..f6fac1d2c1e 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -8,7 +8,6 @@ publish: selector: text: payload: - required: true example: "The temperature is {{ states('sensor.temperature') }}" selector: template: diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 22f64053d23..48ab4676dea 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -28,8 +28,8 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template @@ -113,7 +113,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT siren through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( @@ -217,10 +217,7 @@ class MqttSiren(MqttEntity, SirenEntity): try: json_payload = json_loads_object(payload) _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", json_payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3815b6adbd5..8805f447d69 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,13 +1,5 @@ { "issues": { - "deprecated_color_handling": { - "title": "Deprecated color handling used for MQTT light", - "description": "An MQTT light config (with `json` schema) found in `configuration.yaml` uses deprecated color handling flags.\n\nConfiguration found:\n```yaml\n{config}\n```\nDeprecated flags: **{deprecated_flags}**.\n\nUse the `supported_color_modes` option instead and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, - "deprecated_color_mode_flag": { - "title": "Deprecated color_mode option flag used for MQTT light", - "description": "An MQTT light config (with `json` schema) found in `configuration.yaml` uses a deprecated `color_mode` flag.\n\nConfiguration found:\n```yaml\n{config}\n```\n\nRemove the option from your config and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." @@ -34,6 +26,7 @@ "client_id": "Client ID (leave empty to randomly generated one)", "client_cert": "Upload client certificate file", "client_key": "Upload private key file", + "client_key_password": "[%key:common::config_flow::data::password%]", "keepalive": "The time between sending keep alive messages", "tls_insecure": "Ignore broker certificate validation", "protocol": "MQTT protocol", @@ -53,6 +46,7 @@ "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", + "client_key_password": "The password for the private key file (if set).", "keepalive": "A value less than 90 seconds is advised.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", @@ -101,8 +95,8 @@ "bad_will": "Invalid will topic", "bad_discovery_prefix": "Invalid discovery prefix", "bad_certificate": "The CA certificate is invalid", - "bad_client_cert": "Invalid client certificate, ensure a PEM coded file is supplied", - "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert": "Invalid client certificate, ensure a valid file is supplied", + "client_key_error": "Invalid private key file or invalid password supplied", "bad_client_cert_key": "Client certificate and private key are not a valid pair", "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -215,7 +209,7 @@ "bad_discovery_prefix": "[%key:component::mqtt::config::error::bad_discovery_prefix%]", "bad_certificate": "[%key:component::mqtt::config::error::bad_certificate%]", "bad_client_cert": "[%key:component::mqtt::config::error::bad_client_cert%]", - "bad_client_key": "[%key:component::mqtt::config::error::bad_client_key%]", + "client_key_error": "[%key:component::mqtt::config::error::client_key_error%]", "bad_client_cert_key": "[%key:component::mqtt::config::error::bad_client_cert_key%]", "bad_ws_headers": "[%key:component::mqtt::config::error::bad_ws_headers%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -246,11 +240,7 @@ }, "payload": { "name": "Payload", - "description": "The payload to publish." - }, - "payload_template": { - "name": "Payload template", - "description": "Template to render as a payload value. If a payload is provided, the template is ignored." + "description": "The payload to publish. Publishes an empty message if not provided." }, "qos": { "name": "QoS", diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 0a54bcdb378..f6996fc77ce 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -20,8 +20,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType @@ -70,7 +70,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT switch through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 680f252fb20..9a05d1896f7 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -12,7 +12,7 @@ from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE from homeassistant.core import HassJobType, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index b4ed33a7730..d306fc0819b 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -95,7 +95,7 @@ PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, valid_text_size_configur async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT text through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 99b4e5cb821..c4916b5010c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -82,7 +82,7 @@ MQTT_JSON_UPDATE_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT update entity through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( @@ -151,10 +151,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): rendered_json_payload = json_loads(payload) if isinstance(rendered_json_payload, dict): _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), + "JSON payload detected after processing payload '%s' on topic %s", rendered_json_payload, msg.topic, ) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 743bfb363f3..f1d2eb34fe1 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,8 +17,8 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType from homeassistant.util.json import json_loads_object @@ -175,7 +175,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT vacuum through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 50c5960f801..53f7d06429e 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -23,8 +23,8 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.util.percentage import ( @@ -136,7 +136,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT valve through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 4c1d3fa8a53..31d4f0fe30e 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -35,8 +35,8 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.unit_conversion import TemperatureConverter @@ -166,7 +166,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT water heater device through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 5e677d13cfe..20602e03f81 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import EventOrigin, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 3200da56cf6..6f4e83799d1 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_DEVICES, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 849d4562423..242c39cb983 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 3a0953a0158..9a08fa2c73a 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py index a4de5d126d5..06f9bc42e91 100644 --- a/homeassistant/components/msteams/notify.py +++ b/homeassistant/components/msteams/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index 2e649d9a586..ad488058025 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -28,7 +28,7 @@ BINARY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = hass.data[DOMAIN] diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 052f4f556c1..e569bb93a42 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -15,9 +15,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index f3297bf0a6f..031229d1544 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -16,13 +16,14 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ALBUM_ARTISTS_ONLY, ATTR_ALBUM_TYPE, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, @@ -32,6 +33,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_RADIO, ATTR_SEARCH, ATTR_SEARCH_ALBUM, @@ -48,6 +50,15 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, + ) from . import MusicAssistantConfigEntry @@ -154,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_item_dict_from_mass_item(mass, item) for item in search_results.radio ], + ATTR_AUDIOBOOKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.audiobooks + ], + ATTR_PODCASTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.podcasts + ], } ) return response @@ -173,6 +192,15 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "offset": offset, "order_by": order_by, } + library_result: ( + list[Album] + | list[Artist] + | list[Track] + | list[Radio] + | list[Playlist] + | list[Audiobook] + | list[Podcast] + ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( **base_params, @@ -181,7 +209,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: elif media_type == MediaType.ARTIST: library_result = await mass.music.get_library_artists( **base_params, - album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY), + album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), ) elif media_type == MediaType.TRACK: library_result = await mass.music.get_library_tracks( @@ -195,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: library_result = await mass.music.get_library_playlists( **base_params, ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) else: raise ServiceValidationError(f"Unsupported media type {media_type}") diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index fc50a2d654b..b00924c97a5 100644 --- a/homeassistant/components/music_assistant/config_flow.py +++ b/homeassistant/components/music_assistant/config_flow.py @@ -13,11 +13,11 @@ from music_assistant_client.exceptions import ( from music_assistant_models.api import ServerInfoMessage import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -93,7 +93,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Mass server. diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 1980c495278..d2ee1f75028 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists" ATTR_ALBUMS = "albums" ATTR_TRACKS = "tracks" ATTR_PLAYLISTS = "playlists" +ATTR_AUDIOBOOKS = "audiobooks" +ATTR_PODCASTS = "podcasts" ATTR_RADIO = "radio" ATTR_ITEMS = "items" ATTR_RADIO_MODE = "radio_mode" diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index f5cdcf50673..fb8bb9c3ac2 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.8"], + "requirements": ["music-assistant-client==1.1.1"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index e65d6d4a975..a926e2a0595 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -166,6 +166,8 @@ async def build_playlist_items_listing( ) -> BrowseMedia: """Build Playlist items browse listing.""" playlist = await mass.music.get_item_by_uri(identifier) + if TYPE_CHECKING: + assert playlist.uri is not None return BrowseMedia( media_class=MediaClass.PLAYLIST, @@ -219,6 +221,9 @@ async def build_artist_items_listing( artist = await mass.music.get_item_by_uri(identifier) albums = await mass.music.get_artist_albums(artist.item_id, artist.provider) + if TYPE_CHECKING: + assert artist.uri is not None + return BrowseMedia( media_class=MediaType.ARTIST, media_content_id=artist.uri, @@ -267,6 +272,9 @@ async def build_album_items_listing( album = await mass.music.get_item_by_uri(identifier) tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + if TYPE_CHECKING: + assert album.uri is not None + return BrowseMedia( media_class=MediaType.ALBUM, media_content_id=album.uri, @@ -340,6 +348,9 @@ def build_item( title = item.name img_url = mass.get_media_item_image_url(item) + if TYPE_CHECKING: + assert item.uri is not None + return BrowseMedia( media_class=media_class or item.media_type.value, media_content_id=item.uri, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 9aa7498a2ee..c079fd20e91 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -9,6 +9,7 @@ import functools import os from typing import TYPE_CHECKING, Any, Concatenate +from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, MediaType, @@ -20,6 +21,7 @@ from music_assistant_models.enums import ( from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +from music_assistant_models.player_queue import PlayerQueue import voluptuous as vol from homeassistant.components import media_source @@ -39,10 +41,9 @@ from homeassistant.components.media_player import ( from homeassistant.const import ATTR_NAME, STATE_OFF from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.util.dt import utc_from_timestamp @@ -79,21 +80,15 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player - from music_assistant_models.player_queue import PlayerQueue -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP +SUPPORTED_FEATURES_BASE = ( + MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE @@ -138,7 +133,7 @@ def catch_musicassistant_error[_R, **P]( async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Music Assistant MediaPlayer(s) from Config Entry.""" mass = entry.runtime_data.mass @@ -213,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SET_MEMBERS in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.VOLUME_MUTE in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -242,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) + # we subscribe to the player config changed event to update + # the supported features of the player + async def player_config_changed(event: MassEvent) -> None: + self._set_supported_features() + await self.async_on_update() + self.async_write_ha_state() + + self.async_on_remove( + self.mass.subscribe( + player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id + ) + ) + @property def active_queue(self) -> PlayerQueue | None: """Return the active queue for this player (if any).""" @@ -474,6 +478,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): album=album, media_type=MediaType(media_type) if media_type else None, ): + if TYPE_CHECKING: + assert item.uri is not None media_uris.append(item.uri) if not media_uris: @@ -681,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if isinstance(queue_option, MediaPlayerEnqueue): queue_option = QUEUE_OPTION_MAP.get(queue_option) return queue_option + + def _set_supported_features(self) -> None: + """Set supported features based on player capabilities.""" + supported_features = SUPPORTED_FEATURES_BASE + if PlayerFeature.SET_MEMBERS in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.PAUSE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.PAUSE + if self.player.mute_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.player.volume_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + supported_features |= MediaPlayerEntityFeature.VOLUME_SET + if self.player.power_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.TURN_ON + supported_features |= MediaPlayerEntityFeature.TURN_OFF + self._attr_supported_features = supported_features diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 9caae2ee0b4..7501d3d2038 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -8,13 +8,14 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ACTIVE, ATTR_ALBUM, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_BIT_DEPTH, ATTR_CONTENT_TYPE, ATTR_CURRENT_INDEX, @@ -31,6 +32,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_PROVIDER, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, @@ -65,20 +67,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema( def media_item_dict_from_mass_item( mass: MusicAssistantClient, - item: MediaItemType | ItemMapping | None, -) -> dict[str, Any] | None: + item: MediaItemType | ItemMapping, +) -> dict[str, Any]: """Parse a Music Assistant MediaItem.""" - if not item: - return None - base = { + base: dict[str, Any] = { ATTR_MEDIA_TYPE: item.media_type, ATTR_URI: item.uri, ATTR_NAME: item.name, ATTR_VERSION: item.version, ATTR_IMAGE: mass.get_media_item_image_url(item), } + artists: list[ItemMapping] | None if artists := getattr(item, "artists", None): base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists] + album: ItemMapping | None if album := getattr(item, "album", None): base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album) return base @@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema( vol.Required(ATTR_RADIO): vol.All( cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] ), + vol.Required(ATTR_AUDIOBOOKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PODCASTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), }, ) @@ -151,7 +159,11 @@ def queue_item_dict_from_mass_item( ATTR_QUEUE_ITEM_ID: item.queue_item_id, ATTR_NAME: item.name, ATTR_DURATION: item.duration, - ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item), + ATTR_MEDIA_ITEM: ( + media_item_dict_from_mass_item(mass, item.media_item) + if item.media_item + else None + ), } if streamdetails := item.streamdetails: base[ATTR_STREAM_TITLE] = streamdetails.stream_title diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 73e8e2d7521..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -21,7 +21,10 @@ play_media: options: - artist - album + - audiobook + - folder - playlist + - podcast - track - radio artist: @@ -118,7 +121,9 @@ search: options: - artist - album + - audiobook - playlist + - podcast - track - radio artist: @@ -160,7 +165,9 @@ get_library: options: - artist - album + - audiobook - playlist + - podcast - track - radio favorite: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index af366c94310..7338af7cb65 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -7,15 +7,15 @@ } }, "manual": { - "title": "Manually add Music Assistant Server", - "description": "Enter the URL to your already running Music Assistant Server. If you do not have the Music Assistant Server running, you should install it first.", + "title": "Manually add Music Assistant server", + "description": "Enter the URL to your already running Music Assistant server. If you do not have the Music Assistant server running, you should install it first.", "data": { "url": "URL of the Music Assistant server" } }, "discovery_confirm": { - "description": "Do you want to add the Music Assistant Server `{url}` to Home Assistant?", - "title": "Discovered Music Assistant Server" + "description": "Do you want to add the Music Assistant server `{url}` to Home Assistant?", + "title": "Discovered Music Assistant server" } }, "error": { @@ -34,13 +34,13 @@ "issues": { "invalid_server_version": { "title": "The Music Assistant server is not the correct version", - "description": "Check if there are updates available for the Music Assistant Server and/or integration." + "description": "Check if there are updates available for the Music Assistant server and/or integration." } }, "services": { "play_media": { "name": "Play media", - "description": "Play media on a Music Assistant player with more fine-grained control options.", + "description": "Plays media on a Music Assistant player with more fine-grained control options.", "fields": { "media_id": { "name": "Media ID(s)", @@ -70,7 +70,7 @@ }, "play_announcement": { "name": "Play announcement", - "description": "Play announcement on a Music Assistant player with more fine-grained control options.", + "description": "Plays an announcement on a Music Assistant player with more fine-grained control options.", "fields": { "url": { "name": "URL", @@ -88,7 +88,7 @@ }, "transfer_queue": { "name": "Transfer queue", - "description": "Transfer the player's queue to another player.", + "description": "Transfers a player's queue to another player.", "fields": { "source_player": { "name": "Source media player", @@ -102,11 +102,11 @@ }, "get_queue": { "name": "Get playerQueue details (advanced)", - "description": "Get the details of the currently active queue of a Music Assistant player." + "description": "Retrieves the details of the currently active queue of a Music Assistant player." }, "search": { "name": "Search Music Assistant", - "description": "Perform a global search on the Music Assistant library and all providers.", + "description": "Performs a global search on the Music Assistant library and all providers.", "fields": { "config_entry_id": { "name": "Music Assistant instance", @@ -195,8 +195,11 @@ "options": { "artist": "Artist", "album": "Album", + "audiobook": "Audiobook", + "folder": "Folder", "track": "Track", "playlist": "Playlist", + "podcast": "Podcast", "radio": "Radio" } }, diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 87bf246f4e0..7a9025762ef 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -18,7 +18,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the mütesync button.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index b482de8130c..d8b43517711 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mycroft/__init__.py b/homeassistant/components/mycroft/__init__.py index 557eca972e6..e5893e57a8e 100644 --- a/homeassistant/components/mycroft/__init__.py +++ b/homeassistant/components/mycroft/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "mycroft" diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index 41b36a34c20..47629006887 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 54f7036b79c..d42b2194315 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -71,7 +71,7 @@ SENSORS: dict[str, MySensorsBinarySensorDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index 23b7c47ebf3..d1504f3afab 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform @@ -43,7 +43,7 @@ OPERATION_LIST = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index f3fb03ffac8..e616e325835 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -20,8 +20,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 808589b9022..14e6ff6dc15 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -31,7 +31,7 @@ class CoverState(Enum): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 5abe6a64e2d..56d8b2f5923 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -18,7 +18,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index fa3464c0088..bdc83f30b21 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -22,7 +22,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 74dc99e76d3..c96ad6cea8e 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 87f60174cab..9e4054ca3d0 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import rgb_hex_to_rgb_list from . import setup_mysensors_platform @@ -27,7 +27,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map: dict[SensorType, type[MySensorsChildEntity]] = { diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index 1a4f6fdaa90..ada801f92ab 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -25,7 +25,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index eec3c6bcd79..759cf7b010f 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform @@ -102,6 +102,7 @@ SENSORS: dict[str, SensorEntityDescription] = { key="V_DIRECTION", native_unit_of_measurement=DEGREE, icon="mdi:compass", + device_class=SensorDeviceClass.WIND_DIRECTION, ), "V_WEIGHT": SensorEntityDescription( key="V_WEIGHT", @@ -210,7 +211,7 @@ SENSORS: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 4eabf6374f1..52207c21f77 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType @@ -20,7 +20,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map: dict[SensorType, type[MySensorsSwitch]] = { diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 4edb5ccdbd8..8eff7a255e7 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -18,7 +18,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 5dabb609437..3942f601a20 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER @@ -31,7 +31,9 @@ EFFECT_SUNRISE = "sunrise" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" info = hass.data[DOMAIN][entry.entry_id].info diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 2c35d35dad6..bd5c9b923a2 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER @@ -55,7 +55,9 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index af135027aac..f626656a4e3 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, format_mac -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER @@ -21,7 +21,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" device = hass.data[DOMAIN][entry.entry_id].device diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py index f4de18aa0ef..58ac6051c8a 100644 --- a/homeassistant/components/mythicbeastsdns/__init__.py +++ b/homeassistant/components/mythicbeastsdns/__init__.py @@ -12,8 +12,8 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 5ad114e973e..407e4da5475 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -9,7 +9,6 @@ from aiohttp import ClientError, ClientResponseError import jwt from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,7 +21,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES -from .coordinator import MyUplinkDataCoordinator +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] -type MyUplinkConfigEntry = ConfigEntry[MyUplinkDataCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry @@ -77,7 +74,7 @@ async def async_setup_entry( # Setup MyUplinkAPI and coordinator for data fetch api = MyUplinkAPI(auth) - coordinator = MyUplinkDataCoordinator(hass, api) + coordinator = MyUplinkDataCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index d903c7cbfae..785a7ff4532 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -9,10 +9,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity, MyUplinkSystemEntity from .helpers import find_matching_platform, transform_model_series @@ -58,7 +58,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink binary_sensor.""" entities: list[BinarySensorEntity] = [] diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py index 211fd894ac5..6bf762ad642 100644 --- a/homeassistant/components/myuplink/coordinator.py +++ b/homeassistant/components/myuplink/coordinator.py @@ -7,6 +7,7 @@ import logging from myuplink import Device, DevicePoint, MyUplinkAPI, System +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -23,14 +24,22 @@ class CoordinatorData: time: datetime +type MyUplinkConfigEntry = ConfigEntry[MyUplinkDataCoordinator] + + class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]): """Coordinator for myUplink data.""" - def __init__(self, hass: HomeAssistant, api: MyUplinkAPI) -> None: + config_entry: MyUplinkConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: MyUplinkConfigEntry, api: MyUplinkAPI + ) -> None: """Initialize myUplink coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="myuplink", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/myuplink/diagnostics.py b/homeassistant/components/myuplink/diagnostics.py index 5e26cf273b4..61605a04fc8 100644 --- a/homeassistant/components/myuplink/diagnostics.py +++ b/homeassistant/components/myuplink/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import MyUplinkConfigEntry +from .coordinator import MyUplinkConfigEntry TO_REDACT = {"access_token", "refresh_token", "serialNumber"} diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index bd875d8a872..5751d574e04 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -26,10 +26,8 @@ def find_matching_platform( if len(device_point.enum_values) > 0 and device_point.writable: return Platform.SELECT - if ( - description - and description.native_unit_of_measurement == "DM" - or (device_point.raw["maxValue"] and device_point.raw["minValue"]) + if (description and description.native_unit_of_measurement == "DM") or ( + device_point.raw["maxValue"] and device_point.raw["minValue"] ): if device_point.writable: return Platform.NUMBER diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index 8438d24194c..d3242115acb 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["myuplink==0.6.0"] + "requirements": ["myuplink==0.7.0"] } diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index e1cbd393947..33100850837 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -7,10 +7,10 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import DOMAIN, F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -63,7 +63,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink number.""" entities: list[NumberEntity] = [] diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py index 0074d1c75ff..36f9be63669 100644 --- a/homeassistant/components/myuplink/select.py +++ b/homeassistant/components/myuplink/select.py @@ -9,10 +9,10 @@ from homeassistant.components.select import SelectEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import DOMAIN +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity @@ -20,7 +20,7 @@ from .helpers import find_matching_platform, skip_entity async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink select.""" entities: list[SelectEntity] = [] diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index ef827fc1fb1..3b14cdd4630 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -21,11 +21,11 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -214,7 +214,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink sensor.""" @@ -325,10 +325,10 @@ class MyUplinkEnumSensor(MyUplinkDevicePointSensor): } @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Sensor state value for enum sensor.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] - return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + return self.options_map.get(str(int(device_point.value))) class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 3addc7ce6a9..2d3706f2bdb 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -9,10 +9,10 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import DOMAIN, F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series @@ -55,7 +55,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink switch.""" entities: list[SwitchEntity] = [] diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py index 9e94de0a503..ee259f5cbe8 100644 --- a/homeassistant/components/myuplink/update.py +++ b/homeassistant/components/myuplink/update.py @@ -6,9 +6,9 @@ from homeassistant.components.update import ( UpdateEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity UPDATE_DESCRIPTION = UpdateEntityDescription( @@ -20,7 +20,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entity.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py index e3c22b42d28..c1efa18f72b 100644 --- a/homeassistant/components/nad/media_player.py +++ b/homeassistant/components/nad/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 624415adb12..6b4ca6ff324 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING from aiohttp.client_exceptions import ClientConnectorError, ClientError from nettigo_air_monitor import ( @@ -14,7 +13,6 @@ from nettigo_air_monitor import ( ) from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,14 +20,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ATTR_SDS011, ATTR_SPS30, DOMAIN -from .coordinator import NAMDataUpdateCoordinator +from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BUTTON, Platform.SENSOR] -type NAMConfigEntry = ConfigEntry[NAMDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: """Set up Nettigo as config entry.""" @@ -52,10 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: except AuthFailedError as err: raise ConfigEntryAuthFailed from err - if TYPE_CHECKING: - assert entry.unique_id - - coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) + coordinator = NAMDataUpdateCoordinator(hass, entry, nam) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 8ac56f3d70e..60145e4fe27 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -11,10 +11,10 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NAMConfigEntry, NAMDataUpdateCoordinator +from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 @@ -28,7 +28,9 @@ RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: NAMConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NAMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a Nettigo Air Monitor entities from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 494ce9fdac0..fa94971e2ef 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -17,12 +17,12 @@ from nettigo_air_monitor import ( ) import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -138,7 +138,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.host = discovery_info.host diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py index 5019f0e3a1d..3e2c9c24474 100644 --- a/homeassistant/components/nam/coordinator.py +++ b/homeassistant/components/nam/coordinator.py @@ -1,6 +1,7 @@ """The Nettigo Air Monitor coordinator.""" import logging +from typing import TYPE_CHECKING from nettigo_air_monitor import ( ApiError, @@ -10,6 +11,7 @@ from nettigo_air_monitor import ( ) from tenacity import RetryError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,20 +20,28 @@ from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) +type NAMConfigEntry = ConfigEntry[NAMDataUpdateCoordinator] + class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): """Class to manage fetching Nettigo Air Monitor data.""" + config_entry: NAMConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: NAMConfigEntry, nam: NettigoAirMonitor, - unique_id: str, ) -> None: """Initialize.""" - self.unique_id = unique_id + if TYPE_CHECKING: + assert config_entry.unique_id + + self.unique_id = config_entry.unique_id + self.device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, name="Nettigo Air Monitor", sw_version=nam.software_version, manufacturer=MANUFACTURER, @@ -40,7 +50,11 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): self.nam = nam super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_UPDATE_INTERVAL, ) async def _async_update_data(self) -> NAMSensors: diff --git a/homeassistant/components/nam/diagnostics.py b/homeassistant/components/nam/diagnostics.py index d29eb40ced7..905c1669496 100644 --- a/homeassistant/components/nam/diagnostics.py +++ b/homeassistant/components/nam/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import NAMConfigEntry +from .coordinator import NAMConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 27fae62be8a..4478507dc59 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -27,12 +27,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import NAMConfigEntry, NAMDataUpdateCoordinator from .const import ( ATTR_BME280_HUMIDITY, ATTR_BME280_PRESSURE, @@ -69,6 +68,7 @@ from .const import ( DOMAIN, MIGRATION_SENSORS, ) +from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 @@ -356,7 +356,9 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: NAMConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NAMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a Nettigo Air Monitor entities from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py index 43310c5e922..7fbd49d979b 100644 --- a/homeassistant/components/namecheapdns/__init__.py +++ b/homeassistant/components/namecheapdns/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 4a34c2843aa..7ee1c14a9b1 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -8,7 +8,6 @@ import logging from aionanoleaf import EffectsEvent, Nanoleaf, StateEvent, TouchEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_HOST, @@ -22,23 +21,20 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN, NANOLEAF_EVENT, TOUCH_GESTURE_TRIGGER_MAP, TOUCH_MODELS -from .coordinator import NanoleafCoordinator +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.LIGHT] -type NanoleafConfigEntry = ConfigEntry[NanoleafCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: NanoleafConfigEntry) -> bool: """Set up Nanoleaf from a config entry.""" nanoleaf = Nanoleaf( async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN] ) - coordinator = NanoleafCoordinator(hass, nanoleaf) + coordinator = NanoleafCoordinator(hass, entry, nanoleaf) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py index 34d0f4f5076..813d81ab571 100644 --- a/homeassistant/components/nanoleaf/button.py +++ b/homeassistant/components/nanoleaf/button.py @@ -3,17 +3,16 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NanoleafConfigEntry -from .coordinator import NanoleafCoordinator +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity async def async_setup_entry( hass: HomeAssistant, entry: NanoleafConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nanoleaf button.""" async_add_entities([NanoleafIdentifyButton(entry.runtime_data)]) diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 27ef9a887fe..253387c254a 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -10,11 +10,15 @@ from typing import Any, Final, cast from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object from .const import DOMAIN @@ -86,31 +90,31 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_link() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Zeroconf discovery.""" _LOGGER.debug("Zeroconf discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit discovery.""" _LOGGER.debug("Homekit discovered: %s", discovery_info) return await self._async_homekit_zeroconf_discovery_handler(discovery_info) async def _async_homekit_zeroconf_discovery_handler( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf Homekit and Zeroconf discovery.""" return await self._async_discovery_handler( discovery_info.host, discovery_info.name.replace(f".{discovery_info.type}", ""), - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + discovery_info.properties[ATTR_PROPERTIES_ID], ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle Nanoleaf SSDP discovery.""" _LOGGER.debug("SSDP discovered: %s", discovery_info) diff --git a/homeassistant/components/nanoleaf/coordinator.py b/homeassistant/components/nanoleaf/coordinator.py index e080afc492e..495a63b9164 100644 --- a/homeassistant/components/nanoleaf/coordinator.py +++ b/homeassistant/components/nanoleaf/coordinator.py @@ -5,20 +5,31 @@ import logging from aionanoleaf import InvalidToken, Nanoleaf, Unavailable +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) +type NanoleafConfigEntry = ConfigEntry[NanoleafCoordinator] + class NanoleafCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Nanoleaf data.""" - def __init__(self, hass: HomeAssistant, nanoleaf: Nanoleaf) -> None: + config_entry: NanoleafConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: NanoleafConfigEntry, nanoleaf: Nanoleaf + ) -> None: """Initialize the Nanoleaf data coordinator.""" super().__init__( - hass, _LOGGER, name="Nanoleaf", update_interval=timedelta(minutes=1) + hass, + _LOGGER, + config_entry=config_entry, + name="Nanoleaf", + update_interval=timedelta(minutes=1), ) self.nanoleaf = nanoleaf diff --git a/homeassistant/components/nanoleaf/diagnostics.py b/homeassistant/components/nanoleaf/diagnostics.py index 6f8691905ef..ce2045acf7b 100644 --- a/homeassistant/components/nanoleaf/diagnostics.py +++ b/homeassistant/components/nanoleaf/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from . import NanoleafConfigEntry +from .coordinator import NanoleafConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/nanoleaf/entity.py b/homeassistant/components/nanoleaf/entity.py index ffe4a098022..dd0b455fa0f 100644 --- a/homeassistant/components/nanoleaf/entity.py +++ b/homeassistant/components/nanoleaf/entity.py @@ -3,8 +3,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NanoleafCoordinator from .const import DOMAIN +from .coordinator import NanoleafCoordinator class NanoleafEntity(CoordinatorEntity[NanoleafCoordinator]): diff --git a/homeassistant/components/nanoleaf/event.py b/homeassistant/components/nanoleaf/event.py index 5763c2aa595..78ff889bdc5 100644 --- a/homeassistant/components/nanoleaf/event.py +++ b/homeassistant/components/nanoleaf/event.py @@ -3,17 +3,17 @@ from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NanoleafConfigEntry, NanoleafCoordinator from .const import TOUCH_MODELS +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity async def async_setup_entry( hass: HomeAssistant, entry: NanoleafConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nanoleaf event.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 681053fa573..6d42110d53e 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -15,10 +15,9 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NanoleafConfigEntry -from .coordinator import NanoleafCoordinator +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity RESERVED_EFFECTS = ("*Solid*", "*Static*", "*Dynamic*") @@ -28,7 +27,7 @@ DEFAULT_NAME = "Nanoleaf" async def async_setup_entry( hass: HomeAssistant, entry: NanoleafConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nanoleaf light.""" async_add_entities([NanoleafLight(entry.runtime_data)]) diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 4b4c026260d..7af7465bbd0 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", "homekit": { - "models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59"] + "models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59", "NL69", "NL81"] }, "iot_class": "local_push", "loggers": ["aionanoleaf"], @@ -22,6 +22,12 @@ }, { "st": "nanoleaf:nl52" + }, + { + "st": "nanoleaf:nl69" + }, + { + "st": "inanoleaf:nl81" } ], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."] diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 00e5a21da18..740db1ed1a1 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -10,9 +10,9 @@ from webio_api import Output as NASwebOutput from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, @@ -38,7 +38,7 @@ def _get_output(coordinator: NASwebCoordinator, index: int) -> NASwebOutput | No async def async_setup_entry( hass: HomeAssistant, config: NASwebConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switch platform.""" diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py index 29114ce5188..8658dfd1b1b 100644 --- a/homeassistant/components/neato/button.py +++ b/homeassistant/components/neato/button.py @@ -8,14 +8,16 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_ROBOTS from .entity import NeatoEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato button from config entry.""" entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]] diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index e4d5f81f33a..42278a3a48f 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -13,7 +13,7 @@ from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity @@ -26,7 +26,9 @@ ATTR_GENERATED_AT = "generated_at" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato camera with config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index e4b471cb5ac..ef7cda52f19 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/neato", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.25"] + "requirements": ["pybotvac==0.0.26"] } diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index c247cc48493..4be02fe1ef7 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity @@ -27,7 +27,9 @@ BATTERY = "Battery" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Neato sensor using config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index 25da1c41df1..1ae06fef44c 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity @@ -29,7 +29,9 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato switch with config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 1a9285964a2..a1e1382eb04 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTION, @@ -58,7 +58,9 @@ ATTR_ZONE = "zone" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato vacuum with config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ce3e7d3a002..1e7fc54f4f7 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -17,10 +17,10 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity): self._time = time self._state = None self._trips = None + self._first_trip = None + self._next_trip = None @property def name(self): @@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if not self._trips: + if not self._trips or self._first_trip is None: return None - if self._trips[0].trip_parts: - route = [self._trips[0].departure] - route.extend(k.destination for k in self._trips[0].trip_parts) + if self._first_trip.trip_parts: + route = [self._first_trip.departure] + route.extend(k.destination for k in self._first_trip.trip_parts) # Static attributes attributes = { - "going": self._trips[0].going, + "going": self._first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": self._trips[0].departure_platform_actual, + "departure_platform_planned": self._first_trip.departure_platform_planned, + "departure_platform_actual": self._first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": self._trips[0].arrival_platform_actual, + "arrival_platform_planned": self._first_trip.arrival_platform_planned, + "arrival_platform_actual": self._first_trip.arrival_platform_actual, "next": None, - "status": self._trips[0].status.lower(), - "transfers": self._trips[0].nr_transfers, + "status": self._first_trip.status.lower(), + "transfers": self._first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._trips[0].departure_time_planned is not None: - attributes["departure_time_planned"] = self._trips[ - 0 - ].departure_time_planned.strftime("%H:%M") + if self._first_trip.departure_time_planned is not None: + attributes["departure_time_planned"] = ( + self._first_trip.departure_time_planned.strftime("%H:%M") + ) # Actual departure attributes - if self._trips[0].departure_time_actual is not None: - attributes["departure_time_actual"] = self._trips[ - 0 - ].departure_time_actual.strftime("%H:%M") + if self._first_trip.departure_time_actual is not None: + attributes["departure_time_actual"] = ( + self._first_trip.departure_time_actual.strftime("%H:%M") + ) # Delay departure attributes if ( @@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity): attributes["departure_delay"] = True # Planned arrival attributes - if self._trips[0].arrival_time_planned is not None: - attributes["arrival_time_planned"] = self._trips[ - 0 - ].arrival_time_planned.strftime("%H:%M") + if self._first_trip.arrival_time_planned is not None: + attributes["arrival_time_planned"] = ( + self._first_trip.arrival_time_planned.strftime("%H:%M") + ) # Actual arrival attributes - if self._trips[0].arrival_time_actual is not None: - attributes["arrival_time_actual"] = self._trips[ - 0 - ].arrival_time_actual.strftime("%H:%M") + if self._first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = ( + self._first_trip.arrival_time_actual.strftime("%H:%M") + ) # Delay arrival attributes if ( @@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity): attributes["arrival_delay"] = True # Next attributes - if len(self._trips) > 1: - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime( - "%H:%M" - ) - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime( - "%H:%M" - ) + if self._next_trip.departure_time_actual is not None: + attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") + elif self._next_trip.departure_time_planned is not None: + attributes["next"] = self._next_trip.departure_time_planned.strftime( + "%H:%M" + ) + else: + attributes["next"] = None return attributes @@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity): ): self._state = None self._trips = None + self._first_trip = None return # Set the search parameter to search from a specific trip time @@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity): .strftime("%d-%m-%Y %H:%M") ) else: - trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") try: self._trips = self._nsapi.get_trips( trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: - if self._trips[0].departure_time_actual is None: - planned_time = self._trips[0].departure_time_planned - self._state = planned_time.strftime("%H:%M") + all_times = [] + + # If a train is delayed we can observe this through departure_time_actual. + for trip in self._trips: + if trip.departure_time_actual is None: + all_times.append(trip.departure_time_planned) + else: + all_times.append(trip.departure_time_actual) + + # Remove all trains that already left. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > dt_util.now() + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._first_trip = self._trips[sorted_times[0][0]] + self._state = sorted_times[0][1].strftime("%H:%M") + + # Filter again to remove trains that leave at the exact same time. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > sorted_times[0][1] + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._next_trip = self._trips[sorted_times[0][0]] + else: + self._next_trip = None + else: - actual_time = self._trips[0].departure_time_actual - self._state = actual_time.strftime("%H:%M") + self._first_trip = None + self._state = None + except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, diff --git a/homeassistant/components/ness_alarm/strings.json b/homeassistant/components/ness_alarm/strings.json index ec4e39a6128..f4490ac98db 100644 --- a/homeassistant/components/ness_alarm/strings.json +++ b/homeassistant/components/ness_alarm/strings.json @@ -2,7 +2,7 @@ "services": { "aux": { "name": "Aux", - "description": "Trigger an aux output.", + "description": "Changes the state of an aux output.", "fields": { "output_id": { "name": "Output ID", @@ -10,17 +10,17 @@ }, "state": { "name": "State", - "description": "The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E." + "description": "The on/off state of the output. If P14xE 8E is enabled then turning on will pulse the output for the time specified in P14(x+4)E." } } }, "panic": { "name": "Panic", - "description": "Triggers a panic.", + "description": "Triggers a panic alarm.", "fields": { "code": { "name": "Code", - "description": "The user code to use to trigger the panic." + "description": "The user code to use to trigger the panic alarm." } } } diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 8adc0e4f714..af85f1fc5ae 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import logging -from aiohttp import web +from aiohttp import ClientError, ClientResponseError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage @@ -198,7 +198,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool entry, unique_id=entry.data[CONF_PROJECT_ID] ) - subscriber = await api.new_subscriber(hass, entry) + auth = await api.new_auth(hass, entry) + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + + subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: return False # Keep media for last N events in memory diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index e86e326b1c2..d55826f7ed0 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -50,13 +50,14 @@ class AsyncConfigEntryAuth(AbstractAuth): return cast(str, self._oauth_session.token["access_token"]) async def async_get_creds(self) -> Credentials: - """Return an OAuth credential for Pub/Sub Subscriber.""" - # We don't have a way for Home Assistant to refresh creds on behalf - # of the google pub/sub subscriber. Instead, build a full - # Credentials object with enough information for the subscriber to - # handle this on its own. We purposely don't refresh the token here - # even when it is expired to fully hand off this responsibility and - # know it is working at startup (then if not, fail loudly). + """Return an OAuth credential for Pub/Sub Subscriber. + + The subscriber will call this when connecting to the stream to refresh + the token. We construct a credentials object using the underlying + OAuth2Session since the subscriber may expect the expiry fields to + be present. + """ + await self.async_get_access_token() token = self._oauth_session.token creds = Credentials( # type: ignore[no-untyped-call] token=token["access_token"], @@ -101,9 +102,7 @@ class AccessTokenAuthImpl(AbstractAuth): ) -async def new_subscriber( - hass: HomeAssistant, entry: NestConfigEntry -) -> GoogleNestSubscriber | None: +async def new_auth(hass: HomeAssistant, entry: NestConfigEntry) -> AbstractAuth: """Create a GoogleNestSubscriber.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -114,14 +113,22 @@ async def new_subscriber( implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): raise TypeError(f"Unexpected auth implementation {implementation}") - if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: - subscription_name = entry.data[CONF_SUBSCRIBER_ID] - auth = AsyncConfigEntryAuth( + return AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), implementation.client_id, implementation.client_secret, ) + + +async def new_subscriber( + hass: HomeAssistant, + entry: NestConfigEntry, + auth: AbstractAuth, +) -> GoogleNestSubscriber: + """Create a GoogleNestSubscriber.""" + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] return GoogleNestSubscriber(auth, entry.data[CONF_PROJECT_ID], subscription_name) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index df02f17444f..f5985da9ff8 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -30,7 +30,7 @@ from homeassistant.components.camera import ( from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -51,7 +51,9 @@ BACKOFF_MULTIPLIER = 1.5 async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cameras.""" diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 3193d592120..f5eff664f83 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -30,7 +30,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_info import NestDeviceInfo from .types import NestConfigEntry @@ -76,7 +76,9 @@ MIN_TEMP_RANGE = 1.66667 async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the client entities.""" diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 274e4c288b4..0b249db7a4b 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -15,6 +15,7 @@ import logging from typing import TYPE_CHECKING, Any from google_nest_sdm.admin_client import ( + DEFAULT_TOPIC_IAM_POLICY, AdminClient, EligibleSubscriptions, EligibleTopics, @@ -25,6 +26,11 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util import get_random_string from . import api @@ -41,8 +47,9 @@ from .const import ( ) DATA_FLOW_IMPL = "nest_flow_implementation" +TOPIC_FORMAT = "projects/{cloud_project_id}/topics/home-assistant-{rnd}" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" -SUBSCRIPTION_RAND_LENGTH = 10 +RAND_LENGTH = 10 MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" @@ -59,6 +66,7 @@ DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) +CREATE_NEW_TOPIC_KEY = "create_new_topic" CREATE_NEW_SUBSCRIPTION_KEY = "create_new_subscription" _LOGGER = logging.getLogger(__name__) @@ -66,10 +74,16 @@ _LOGGER = logging.getLogger(__name__) def _generate_subscription_id(cloud_project_id: str) -> str: """Create a new subscription id.""" - rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH) + rnd = get_random_string(RAND_LENGTH) return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) +def _generate_topic_id(cloud_project_id: str) -> str: + """Create a new topic id.""" + rnd = get_random_string(RAND_LENGTH) + return TOPIC_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) + + def generate_config_title(structures: Iterable[Structure]) -> str | None: """Pick a user friendly config title based on the Google Home name(s).""" names: list[str] = [ @@ -130,7 +144,7 @@ class NestFlowHandler( if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self._async_finish() - return await self.async_step_pubsub() + return await self.async_step_pubsub_topic() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -192,7 +206,9 @@ class NestFlowHandler( ) -> ConfigFlowResult: """Handle cloud project in user input.""" if user_input is not None: - self._data.update(user_input) + self._data[CONF_CLOUD_PROJECT_ID] = user_input[ + CONF_CLOUD_PROJECT_ID + ].strip() return await self.async_step_device_project() return self.async_show_form( step_id="cloud_project", @@ -213,7 +229,7 @@ class NestFlowHandler( """Collect device access project from user input.""" errors = {} if user_input is not None: - project_id = user_input[CONF_PROJECT_ID] + project_id = user_input[CONF_PROJECT_ID].strip() if project_id == self._data[CONF_CLOUD_PROJECT_ID]: _LOGGER.error( "Device Access Project ID and Cloud Project ID must not be the" @@ -240,72 +256,83 @@ class NestFlowHandler( errors=errors, ) - async def async_step_pubsub( + async def async_step_pubsub_topic( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Configure and the pre-requisites to configure Pub/Sub topics and subscriptions.""" - data = { - **self._data, - **(user_input if user_input is not None else {}), - } - cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() - device_access_project_id = data[CONF_PROJECT_ID] - - errors: dict[str, str] = {} - if cloud_project_id: + """Configure and create Pub/Sub topic.""" + cloud_project_id = self._data[CONF_CLOUD_PROJECT_ID] + if self._admin_client is None: access_token = self._data["token"]["access_token"] self._admin_client = api.new_pubsub_admin_client( - self.hass, access_token=access_token, cloud_project_id=cloud_project_id + self.hass, + access_token=access_token, + cloud_project_id=cloud_project_id, ) - try: - eligible_topics = await self._admin_client.list_eligible_topics( - device_access_project_id=device_access_project_id - ) - except ApiException as err: - _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) - errors["base"] = "pubsub_api_error" - else: - if not eligible_topics.topic_names: - errors["base"] = "no_pubsub_topics" + errors = {} + if user_input is not None: + topic_name = user_input[CONF_TOPIC_NAME] + if topic_name == CREATE_NEW_TOPIC_KEY: + topic_name = _generate_topic_id(cloud_project_id) + _LOGGER.debug("Creating topic %s", topic_name) + try: + await self._admin_client.create_topic(topic_name) + await self._admin_client.set_topic_iam_policy( + topic_name, DEFAULT_TOPIC_IAM_POLICY + ) + except ApiException as err: + _LOGGER.error("Error creating Pub/Sub topic: %s", err) + errors["base"] = "pubsub_api_error" if not errors: - self._data[CONF_CLOUD_PROJECT_ID] = cloud_project_id - self._eligible_topics = eligible_topics - return await self.async_step_pubsub_topic() + self._data[CONF_TOPIC_NAME] = topic_name + return await self.async_step_pubsub_topic_confirm() + device_access_project_id = self._data[CONF_PROJECT_ID] + try: + eligible_topics = await self._admin_client.list_eligible_topics( + device_access_project_id=device_access_project_id + ) + except ApiException as err: + _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) + return self.async_abort(reason="pubsub_api_error") + topics = [ + *eligible_topics.topic_names, # Untranslated topic paths + CREATE_NEW_TOPIC_KEY, + ] return self.async_show_form( - step_id="pubsub", + step_id="pubsub_topic", data_schema=vol.Schema( { - vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str, + vol.Required( + CONF_TOPIC_NAME, default=next(iter(topics)) + ): SelectSelector( + SelectSelectorConfig( + translation_key="topic_name", + mode=SelectSelectorMode.LIST, + options=topics, + ) + ) } ), description_placeholders={ - "url": CLOUD_CONSOLE_URL, "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, "more_info_url": MORE_INFO_URL, }, errors=errors, ) - async def async_step_pubsub_topic( - self, user_input: dict[str, Any] | None = None + async def async_step_pubsub_topic_confirm( + self, user_input: dict | None = None ) -> ConfigFlowResult: - """Configure and create Pub/Sub topic.""" - if TYPE_CHECKING: - assert self._eligible_topics + """Have the user confirm the Pub/Sub topic is set correctly in Device Access Console.""" if user_input is not None: - self._data.update(user_input) return await self.async_step_pubsub_subscription() - topics = list(self._eligible_topics.topic_names) return self.async_show_form( - step_id="pubsub_topic", - data_schema=vol.Schema( - { - vol.Optional(CONF_TOPIC_NAME, default=topics[0]): vol.In(topics), - } - ), + step_id="pubsub_topic_confirm", description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "topic_name": self._data[CONF_TOPIC_NAME], "more_info_url": MORE_INFO_URL, }, ) @@ -362,7 +389,7 @@ class NestFlowHandler( ) return await self._async_finish() - subscriptions = {} + subscriptions = [] try: eligible_subscriptions = ( await self._admin_client.list_eligible_subscriptions( @@ -375,10 +402,8 @@ class NestFlowHandler( ) errors["base"] = "pubsub_api_error" else: - subscriptions.update( - {name: name for name in eligible_subscriptions.subscription_names} - ) - subscriptions[CREATE_NEW_SUBSCRIPTION_KEY] = "Create New" + subscriptions.extend(eligible_subscriptions.subscription_names) + subscriptions.append(CREATE_NEW_SUBSCRIPTION_KEY) return self.async_show_form( step_id="pubsub_subscription", data_schema=vol.Schema( @@ -386,7 +411,13 @@ class NestFlowHandler( vol.Optional( CONF_SUBSCRIPTION_NAME, default=next(iter(subscriptions)), - ): vol.In(subscriptions), + ): SelectSelector( + SelectSelectorConfig( + translation_key="subscription_name", + mode=SelectSelectorMode.LIST, + options=subscriptions, + ) + ) } ), description_placeholders={ diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index facd429b139..8241b8aa5f8 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -7,7 +7,6 @@ from collections.abc import Mapping from google_nest_sdm.device import Device from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -84,8 +83,7 @@ def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of all nest devices for all config entries.""" return { device.name: device - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.state == ConfigEntryState.LOADED + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN) for device in config_entry.runtime_data.device_manager.devices.values() } diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index 1a2c0317496..9bb041fce6c 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -13,7 +13,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_info import NestDeviceInfo from .events import ( @@ -66,7 +66,9 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" async_add_entities( diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index e14474dc309..a0d8bc06640 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.0.0"] + "requirements": ["google-nest-sdm==7.1.3"] } diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 02a0e305813..a6fda48fe87 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_info import NestDeviceInfo from .types import NestConfigEntry @@ -31,7 +31,9 @@ DEVICE_TYPE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a31a2856544..23da524ab7e 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -17,7 +17,7 @@ }, "device_project": { "title": "Nest: Create a Device Access Project", - "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", + "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", "data": { "project_id": "Device Access Project ID" } @@ -25,20 +25,18 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pubsub": { - "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", - "data": { - "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" - } - }, "pubsub_topic": { "title": "Configure Cloud Pub/Sub topic", - "description": "Nest devices publish updates on a Cloud Pub/Sub topic. Select the Pub/Sub topic below that is the same as the [Device Access Console]({device_access_console_url}). See the integration documentation for [more info]({more_info_url}).", + "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).", "data": { "topic_name": "Pub/Sub topic Name" } }, + "pubsub_topic_confirm": { + "title": "Enable events", + "description": "The Nest Device Access Console needs to be configured to publish device events to your Pub/Sub topic.\n\n1. Visit the [Device Access Console]({device_access_console_url}).\n2. Open the project.\n3. Enable *Events* and set the Pub/Sub topic name to `{topic_name}`\n4. Click *Add & Validate* to verify the topic is configured correctly.\n\nSee the integration documentation for [more info]({more_info_url}).", + "submit": "Confirm" + }, "pubsub_subscription": { "title": "Configure Cloud Pub/Sub subscription", "description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).", @@ -70,7 +68,8 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "pubsub_api_error": "[%key:component::nest::config::error::pubsub_api_error%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -109,5 +108,17 @@ } } } + }, + "selector": { + "topic_name": { + "options": { + "create_new_topic": "Create new topic" + } + }, + "subscription_name": { + "options": { + "create_new_subscription": "Create new subscription" + } + } } } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 6f14c9c76bb..9c92724c543 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -257,7 +257,6 @@ async def async_remove_config_entry_device( return not any( identifier for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - and identifier[1] in modules + if (identifier[0] == DOMAIN and identifier[1] in modules) or identifier[1] in rooms ) diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index c478525753a..d35bfa7e8a6 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NETATMO_CREATE_WEATHER_SENSOR from .data_handler import NetatmoDevice @@ -23,7 +23,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Netatmo binary sensors based on a config entry.""" diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py new file mode 100644 index 00000000000..e77b5188067 --- /dev/null +++ b/homeassistant/components/netatmo/button.py @@ -0,0 +1,73 @@ +"""Support for Netatmo/Bubendorff button.""" + +from __future__ import annotations + +import logging + +from pyatmo import modules as NaModules + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoModuleEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Netatmo button platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCoverPreferredPositionButton(netatmo_device) + _LOGGER.debug("Adding button %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BUTTON, _create_entity) + ) + + +class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): + """Representation of a Netatmo cover preferred position button device.""" + + _attr_configuration_url = CONF_URL_CONTROL + _attr_entity_registry_enabled_default = False + _attr_translation_key = "preferred_position" + device: NaModules.Shutter + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device) + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self.home.entity_id, + SIGNAL_NAME: f"{HOME}-{self.home.entity_id}", + }, + ] + ) + self._attr_unique_id = ( + f"{self.device.entity_id}-{self.device_type}-preferred_position" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + # No state to update for button + + async def async_press(self) -> None: + """Handle button press to move the cover to a preferred position.""" + _LOGGER.debug("Moving %s to a preferred position", self.device.entity_id) + await self.device.async_move_to_preferred_position() diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 3bd7bcd859d..f21998bbac8 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_CAMERA_LIGHT_MODE, @@ -48,7 +48,9 @@ DEFAULT_QUALITY = "high" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera platform.""" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 02c955beac3..2e3d8c6bcb8 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -30,7 +30,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( @@ -118,7 +118,9 @@ NA_VALVE = DeviceType.NRV async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 74f2ebc84b2..d69a62f37f9 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -10,6 +10,7 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, @@ -45,6 +46,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_BUTTON = "netatmo_create_button" NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index c34b3a1b47b..a599aacd719 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo cover platform.""" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 3a28c3b8336..283ccc3740e 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -33,6 +33,7 @@ from .const import ( DOMAIN, MANUFACTURER, NETATMO_CREATE_BATTERY, + NETATMO_CREATE_BUTTON, NETATMO_CREATE_CAMERA, NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, @@ -350,7 +351,10 @@ class NetatmoDataHandler: NETATMO_CREATE_CAMERA_LIGHT, ], NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT], - NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER], + NetatmoDeviceCategory.shutter: [ + NETATMO_CREATE_COVER, + NETATMO_CREATE_BUTTON, + ], NetatmoDeviceCategory.switch: [ NETATMO_CREATE_LIGHT, NETATMO_CREATE_SWITCH, diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 71a8c548622..b0dc74c2b58 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -28,14 +28,14 @@ PRESETS = {v: k for k, v in PRESET_MAPPING.items()} async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo fan platform.""" @callback def _create_entity(netatmo_device: NetatmoDevice) -> None: entity = NetatmoFan(netatmo_device) - _LOGGER.debug("Adding cover %s", entity) + _LOGGER.debug("Adding fan %s", entity) async_add_entities([entity]) entry.async_on_unload( diff --git a/homeassistant/components/netatmo/icons.json b/homeassistant/components/netatmo/icons.json index 9f712e08f33..099c6aa1784 100644 --- a/homeassistant/components/netatmo/icons.json +++ b/homeassistant/components/netatmo/icons.json @@ -13,6 +13,11 @@ } } }, + "button": { + "preferred_position": { + "default": "mdi:window-shutter-auto" + } + }, "sensor": { "temp_trend": { "default": "mdi:trending-up" diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index fe30dc0eaa4..ce28c455dea 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_CONTROL, @@ -30,7 +30,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera light platform.""" diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 92568b73e80..e8637c90584 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_ENERGY, @@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform schedule selector.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index cc233dcc0ce..5f8084d542c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -38,7 +38,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -385,7 +385,9 @@ BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo sensor platform.""" diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index cab0528199d..c130d8e96e3 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -39,7 +39,7 @@ set_preset_mode_with_end_datetime: select: options: - "away" - - "Frost Guard" + - "frost_guard" end_datetime: required: true example: '"2019-04-20 05:04:20"' diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 6b91aa204b2..23b800e460d 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,11 @@ } } }, + "button": { + "preferred_position": { + "name": "Preferred position" + } + }, "sensor": { "temp_trend": { "name": "Temperature trend" diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 6ba4628a358..9ee37c11528 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo switch platform.""" diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index f33349c56ce..4346cbe8689 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index e5b9ec209c7..726c1b2296d 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -12,7 +12,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER @@ -38,7 +38,9 @@ BUTTONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 965e3618645..a0a5b76eee5 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -24,6 +23,12 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from homeassistant.util.network import is_ipv4_address from .const import ( @@ -129,7 +134,7 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Initialize flow from ssdp.""" updated_data: dict[str, str | int | bool] = {} @@ -144,10 +149,10 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info) - if ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp: + if ATTR_UPNP_SERIAL not in discovery_info.upnp: return self.async_abort(reason="no_serial") - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_SERIAL]) self._abort_if_unique_id_configured(updates=updated_data) if device_url.scheme == "https": @@ -157,18 +162,14 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): updated_data[CONF_PORT] = DEFAULT_PORT for model in MODELS_PORT_80: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( - model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_80 for model in MODELS_PORT_5555: - if discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NUMBER, "").startswith( + if discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, "").startswith( model - ) or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").startswith( - model - ): + ) or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "").startswith(model): updated_data[CONF_PORT] = PORT_5555 updated_data[CONF_SSL] = True diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index f7a683326d3..c8ecd8e7e1d 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -62,6 +62,7 @@ MODELS_V2 = [ "RBR", "RBS", "RBW", + "RS", "LBK", "LBR", "CBK", diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index b17430d2abb..56f4ecac14f 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER @@ -18,7 +18,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 1e4bf2480e9..d81f556193b 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -210,6 +210,12 @@ class NetgearRouter: for device in self.devices.values(): device["active"] = now - device["last_seen"] <= self._consider_home + if not device["active"]: + device["link_rate"] = None + device["signal"] = None + device["ip"] = None + device["ssid"] = None + device["conn_ap_mac"] = None if new_device: _LOGGER.debug("Netgear tracker: new device found") diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 72087dd28db..521e18098eb 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -47,18 +47,21 @@ SENSOR_TYPES = { key="type", translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "link_rate": SensorEntityDescription( key="link_rate", translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "signal": SensorEntityDescription( key="signal", translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "ssid": SensorEntityDescription( key="ssid", @@ -69,6 +72,7 @@ SENSOR_TYPES = { key="conn_ap_mac", translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } @@ -270,7 +274,9 @@ SENSOR_LINK_TYPES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] @@ -326,8 +332,6 @@ async def async_setup_entry( class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """Representation of a device connected to a Netgear router.""" - _attr_entity_registry_enabled_default = False - def __init__( self, coordinator: DataUpdateCoordinator, @@ -342,6 +346,11 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): self._attr_unique_id = f"{self._mac}-{attribute}" self._state = device.get(attribute) + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._device.get(self._attribute) is not None + @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 85f214d784a..dd8468df099 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER @@ -99,7 +99,9 @@ ROUTER_SWITCH_TYPES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index 1fbfee3d892..388ad8bff4f 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -12,7 +12,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER @@ -23,7 +23,9 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 1846d1f7992..47a39a39be0 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -6,7 +6,6 @@ from aiohttp.cookiejar import CookieJar import eternalegypt from eternalegypt.eternalegypt import SMS -from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -23,7 +22,7 @@ from .const import ( DATA_SESSION, DOMAIN, ) -from .coordinator import NetgearLTEDataUpdateCoordinator +from .coordinator import NetgearLTEConfigEntry, NetgearLTEDataUpdateCoordinator from .services import async_setup_services EVENT_SMS = "netgear_lte_sms" @@ -55,7 +54,6 @@ PLATFORMS = [ Platform.NOTIFY, Platform.SENSOR, ] -type NetgearLTEConfigEntry = ConfigEntry[NetgearLTEDataUpdateCoordinator] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -94,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - await modem.add_sms_listener(fire_sms_event) - coordinator = NetgearLTEDataUpdateCoordinator(hass, modem) + coordinator = NetgearLTEDataUpdateCoordinator(hass, entry, modem) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -118,12 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): hass.data.pop(DOMAIN, None) for service_name in hass.services.async_services()[DOMAIN]: hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 280d240b90f..890bcb37443 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -9,9 +9,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NetgearLTEConfigEntry +from .coordinator import NetgearLTEConfigEntry from .entity import LTEEntity BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( @@ -39,7 +39,7 @@ BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NetgearLTEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netgear LTE binary sensor.""" async_add_entities( diff --git a/homeassistant/components/netgear_lte/coordinator.py b/homeassistant/components/netgear_lte/coordinator.py index afd0cb743bf..7bcefca6403 100644 --- a/homeassistant/components/netgear_lte/coordinator.py +++ b/homeassistant/components/netgear_lte/coordinator.py @@ -3,17 +3,16 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING from eternalegypt.eternalegypt import Error, Information, Modem +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import NetgearLTEConfigEntry +type NetgearLTEConfigEntry = ConfigEntry[NetgearLTEDataUpdateCoordinator] class NetgearLTEDataUpdateCoordinator(DataUpdateCoordinator[Information]): @@ -24,12 +23,14 @@ class NetgearLTEDataUpdateCoordinator(DataUpdateCoordinator[Information]): def __init__( self, hass: HomeAssistant, + config_entry: NetgearLTEConfigEntry, modem: Modem, ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 3353da6dc77..9d56605b7d2 100644 --- a/homeassistant/components/netgear_lte/entity.py +++ b/homeassistant/components/netgear_lte/entity.py @@ -5,9 +5,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NetgearLTEConfigEntry from .const import DOMAIN, MANUFACTURER -from .coordinator import NetgearLTEDataUpdateCoordinator +from .coordinator import NetgearLTEConfigEntry, NetgearLTEDataUpdateCoordinator class LTEEntity(CoordinatorEntity[NetgearLTEDataUpdateCoordinator]): diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 73e5de7eaeb..49301267d9d 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -19,10 +19,10 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import NetgearLTEConfigEntry +from .coordinator import NetgearLTEConfigEntry from .entity import LTEEntity @@ -127,7 +127,7 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NetgearLTEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netgear LTE sensor.""" async_add_entities(NetgearLTESensor(entry, description) for description in SENSORS) diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 5c2b93bcae7..4560b7a2ecc 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 10046f75127..200cce86997 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -20,7 +20,7 @@ from .const import ( PUBLIC_TARGET_IP, ) from .models import Adapter -from .network import Network, async_get_network +from .network import Network, async_get_loaded_network, async_get_network _LOGGER = logging.getLogger(__name__) @@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: return network.adapters +@callback +def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]: + """Get the network adapter configuration.""" + return async_get_loaded_network(hass).adapters + + @bind_hass async def async_get_source_ip( hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED @@ -74,7 +80,14 @@ async def async_get_enabled_source_ips( hass: HomeAssistant, ) -> list[IPv4Address | IPv6Address]: """Build the list of enabled source ips.""" - adapters = await async_get_adapters(hass) + return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass)) + + +@callback +def async_get_enabled_source_ips_from_adapters( + adapters: list[Adapter], +) -> list[IPv4Address | IPv6Address]: + """Build the list of enabled source ips.""" sources: list[IPv4Address | IPv6Address] = [] for adapter in adapters: if not adapter["enabled"]: @@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_commands, ) + await async_get_network(hass) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 6c5b6f80eda..d8c8858be72 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -6,14 +6,12 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" STORAGE_VERSION: Final = 1 -DATA_NETWORK: Final = "network" - ATTR_ADAPTERS: Final = "adapters" ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index 4158307bb1a..db25bedcaea 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_CONFIGURED_ADAPTERS, - DATA_NETWORK, DEFAULT_CONFIGURED_ADAPTERS, + DOMAIN, STORAGE_KEY, STORAGE_VERSION, ) @@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada _LOGGER = logging.getLogger(__name__) +DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN) -@singleton(DATA_NETWORK) + +@callback +def async_get_loaded_network(hass: HomeAssistant) -> Network: + """Get network singleton.""" + return hass.data[DATA_NETWORK] + + +@singleton(DOMAIN) async def async_get_network(hass: HomeAssistant) -> Network: """Get network singleton.""" network = Network(hass) diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json new file mode 100644 index 00000000000..6aca7343221 --- /dev/null +++ b/homeassistant/components/network/strings.json @@ -0,0 +1,10 @@ +{ + "system_health": { + "info": { + "adapters": "Adapters", + "ipv4_addresses": "IPv4 addresses", + "ipv6_addresses": "IPv6 addresses", + "announce_addresses": "Announce addresses" + } + } +} diff --git a/homeassistant/components/network/system_health.py b/homeassistant/components/network/system_health.py new file mode 100644 index 00000000000..ebabe055539 --- /dev/null +++ b/homeassistant/components/network/system_health.py @@ -0,0 +1,53 @@ +"""Provide info to system health.""" + +from typing import Any + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from . import Adapter, async_get_adapters, async_get_announce_addresses +from .models import IPv4ConfiguredAddress, IPv6ConfiguredAddress + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info, "/config/network") + + +def _format_ips(ips: list[IPv4ConfiguredAddress] | list[IPv6ConfiguredAddress]) -> str: + return ", ".join([f"{ip['address']}/{ip['network_prefix']!s}" for ip in ips]) + + +def _get_adapter_info(adapter: Adapter) -> str: + state = "enabled" if adapter["enabled"] else "disabled" + default = ", default" if adapter["default"] else "" + auto = ", auto" if adapter["auto"] else "" + return f"{adapter['name']} ({state}{default}{auto})" + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + + adapters = await async_get_adapters(hass) + data: dict[str, Any] = { + # k: v for adapter in adapters for k, v in _get_adapter_info(adapter).items() + "adapters": ", ".join([_get_adapter_info(adapter) for adapter in adapters]), + "ipv4_addresses": ", ".join( + [ + f"{adapter['name']} ({_format_ips(adapter['ipv4'])})" + for adapter in adapters + ] + ), + "ipv6_addresses": ", ".join( + [ + f"{adapter['name']} ({_format_ips(adapter['ipv6'])})" + for adapter in adapters + ] + ), + "announce_addresses": ", ".join(await async_get_announce_addresses(hass)), + } + + return data diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 5c6482da59a..7a7ceff338e 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index 66a8ec5bdb8..8d0d509f8a2 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NexiaConfigEntry) -> boo f"Error connecting to Nexia service: {os_error}" ) from os_error - coordinator = NexiaDataUpdateCoordinator(hass, nexia_home) + coordinator = NexiaDataUpdateCoordinator(hass, entry, nexia_home) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py index 204d84ed975..224836c81e6 100644 --- a/homeassistant/components/nexia/binary_sensor.py +++ b/homeassistant/components/nexia/binary_sensor.py @@ -2,7 +2,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import NexiaThermostatEntity from .types import NexiaConfigEntry @@ -11,7 +11,7 @@ from .types import NexiaConfigEntry async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index becd664756b..e9de81cca7c 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -32,9 +32,8 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType @@ -117,7 +116,7 @@ NEXIA_SUPPORTED = ( async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/coordinator.py b/homeassistant/components/nexia/coordinator.py index 894c491c45b..85e784218f4 100644 --- a/homeassistant/components/nexia/coordinator.py +++ b/homeassistant/components/nexia/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from nexia.home import NexiaHome +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,9 +20,12 @@ DEFAULT_UPDATE_RATE = 120 class NexiaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """DataUpdateCoordinator for nexia homes.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, nexia_home: NexiaHome, ) -> None: """Initialize DataUpdateCoordinator for the nexia home.""" @@ -29,6 +33,7 @@ class NexiaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Nexia update", update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE), always_update=False, diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 0013cd63de1..6a439f869c9 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.8"] + "requirements": ["nexia==2.0.9"] } diff --git a/homeassistant/components/nexia/number.py b/homeassistant/components/nexia/number.py index 46cc4d094a3..05d9e5b4614 100644 --- a/homeassistant/components/nexia/number.py +++ b/homeassistant/components/nexia/number.py @@ -7,7 +7,7 @@ from nexia.thermostat import NexiaThermostat from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatEntity @@ -18,7 +18,7 @@ from .util import percent_conv async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py index 60078fab822..fe75eb07e02 100644 --- a/homeassistant/components/nexia/scene.py +++ b/homeassistant/components/nexia/scene.py @@ -6,7 +6,7 @@ from nexia.automation import NexiaAutomation from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import ATTR_DESCRIPTION @@ -20,7 +20,7 @@ SCENE_ACTIVATION_TIME = 5 async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up automations for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index e50bd750c2f..293a9308cb4 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity from .types import NexiaConfigEntry @@ -22,7 +22,7 @@ from .util import percent_conv async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for a Nexia device.""" diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 9505538e86a..1897ad67414 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -10,7 +10,7 @@ from nexia.zone import NexiaThermostatZone from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity @@ -20,7 +20,7 @@ from .types import NexiaConfigEntry async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 554814fe2db..2e184e13fc7 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" _LOGGER.debug(config.data) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index a487a3f1414..3edff53919d 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -9,7 +9,6 @@ from nextcloudmonitor import ( NextcloudMonitorRequestError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_URL, @@ -21,15 +20,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE) _LOGGER = logging.getLogger(__name__) -type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NextcloudConfigEntry) -> bool: """Set up the Nextcloud integration.""" diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index c9d19efbd45..f51796e6c7f 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -10,9 +10,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ @@ -54,7 +54,7 @@ BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ async def async_setup_entry( hass: HomeAssistant, entry: NextcloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nextcloud binary sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index b5dc5e29507..d6bccec07bb 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -14,12 +14,16 @@ from .const import DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] + class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" + config_entry: NextcloudConfigEntry + def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: NextcloudConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" self.ncm = ncm @@ -28,6 +32,7 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=self.url, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 6632b2674eb..f2ebba7fdb2 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NextcloudConfigEntry from .const import DOMAIN -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 19ac7bb0df7..63b31f0edde 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -20,10 +20,10 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utc_from_timestamp -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" @@ -602,7 +602,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ async def async_setup_entry( hass: HomeAssistant, entry: NextcloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nextcloud sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index f9f7e4c2294..9b22a6924bc 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -21,7 +21,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "connection_error_during_import": "Connection error occured during yaml configuration import", + "connection_error_during_import": "Connection error occurred during yaml configuration import", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { @@ -70,7 +70,7 @@ "name": "Cache memory" }, "nextcloud_cache_num_entries": { - "name": "Cache number of entires" + "name": "Cache number of entries" }, "nextcloud_cache_num_hits": { "name": "Cache number of hits" diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py index 5b9de52ad1d..b991b001117 100644 --- a/homeassistant/components/nextcloud/update.py +++ b/homeassistant/components/nextcloud/update.py @@ -4,16 +4,16 @@ from __future__ import annotations from homeassistant.components.update import UpdateEntity, UpdateEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity async def async_setup_entry( hass: HomeAssistant, entry: NextcloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nextcloud update entity.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 7f0729bca1e..478ff215c30 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -98,7 +98,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b # Independent DataUpdateCoordinator is used for each API endpoint to avoid # unnecessary requests when entities using this endpoint are disabled. for coordinator_name, coordinator_class, update_interval in COORDINATORS: - coordinator = coordinator_class(hass, nextdns, profile_id, update_interval) + coordinator = coordinator_class( + hass, entry, nextdns, profile_id, update_interval + ) tasks.append(coordinator.async_config_entry_first_refresh()) coordinators[coordinator_name] = coordinator diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index 08a1f89418f..ed244146efc 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry @@ -51,7 +51,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" coordinator = entry.runtime_data.connection diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index 164d725b393..b36c243a463 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -7,7 +7,7 @@ from nextdns import AnalyticsStatus from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry @@ -25,7 +25,7 @@ CLEAR_LOGS_BUTTON = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add aNextDNS entities from a config_entry.""" coordinator = entry.runtime_data.status diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 6b35e35a027..850702e4488 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -1,8 +1,10 @@ """NextDns coordinator.""" +from __future__ import annotations + from datetime import timedelta import logging -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -25,6 +27,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import NextDnsConfigEntry + from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -35,9 +40,12 @@ CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData) class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): """Class to manage fetching NextDNS data API.""" + config_entry: NextDnsConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, update_interval: timedelta, @@ -54,7 +62,13 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): name=self.profile_name, ) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> CoordinatorDataT: """Update data via internal method.""" diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index ef2b5140fa1..0a4a8eaad8f 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -286,7 +286,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a NextDNS entities from a config_entry.""" async_add_entities( diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 37ff22c7521..b7c77bd9dbd 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry @@ -525,7 +525,7 @@ SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" coordinator = entry.runtime_data.settings diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index ae7a4e615d4..50674a7ed46 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index dd6b15400d9..f6d9bcde432 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -20,7 +20,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index b3ceb00a834..ac201ed2322 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -81,7 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) ) - coordinator = CoilCoordinator(hass, heatpump, connection) + coordinator = CoilCoordinator(hass, entry, heatpump, connection) data = hass.data.setdefault(DOMAIN, {}) data[entry.entry_id] = coordinator diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 0cb16bf4485..284e4d83569 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySenso from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -18,7 +18,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index df8ceef6479..849912af656 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -9,7 +9,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER @@ -19,7 +19,7 @@ from .coordinator import CoilCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 94db90e7f58..1b8a0ecc0df 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -27,7 +27,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -44,7 +44,7 @@ from .coordinator import CoilCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index ed6d18f7888..2451e2fbda9 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -12,7 +12,7 @@ from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Series -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -71,12 +71,17 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, heatpump: HeatPump, connection: Connection, ) -> None: """Initialize coordinator.""" super().__init__( - hass, LOGGER, name="Nibe Heat Pump", update_interval=timedelta(seconds=60) + hass, + LOGGER, + config_entry=config_entry, + name="Nibe Heat Pump", + update_interval=timedelta(seconds=60), ) self.data = {} diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index cb379139eed..d85e5e9b765 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -8,7 +8,7 @@ from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -18,7 +18,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index 3aecff94649..c92c12a882a 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -8,7 +8,7 @@ from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -18,7 +18,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index d34fed50977..ac4f9eba308 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -127,7 +127,7 @@ UNIT_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 72b7c20c7b3..2daf3fc48ff 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -20,7 +20,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index f53df596d27..a72851e7eab 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -17,7 +17,7 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -32,7 +32,7 @@ from .coordinator import CoilCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nice_go/__init__.py b/homeassistant/components/nice_go/__init__.py index b217112c192..a8d2bd71ac4 100644 --- a/homeassistant/components/nice_go/__init__.py +++ b/homeassistant/components/nice_go/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from .coordinator import NiceGOUpdateCoordinator +from .coordinator import NiceGOConfigEntry, NiceGOUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ @@ -18,13 +17,11 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] -type NiceGOConfigEntry = ConfigEntry[NiceGOUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NiceGOConfigEntry) -> bool: """Set up Nice G.O. from a config entry.""" - coordinator = NiceGOUpdateCoordinator(hass) + coordinator = NiceGOUpdateCoordinator(hass, entry) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_ha_stop) ) diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py index da3940117e9..291d4221d6c 100644 --- a/homeassistant/components/nice_go/config_flow.py +++ b/homeassistant/components/nice_go/config_flow.py @@ -50,7 +50,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -92,7 +92,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): ) except AuthFailedError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index 07b20bbbf10..ffdd9dbd518 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -54,19 +54,18 @@ class NiceGODevice: vacation_mode: bool | None +type NiceGOConfigEntry = ConfigEntry[NiceGOUpdateCoordinator] + + class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): """DataUpdateCoordinator for Nice G.O.""" - config_entry: ConfigEntry + config_entry: NiceGOConfigEntry organization_id: str - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: NiceGOConfigEntry) -> None: """Initialize DataUpdateCoordinator for Nice G.O.""" - super().__init__( - hass, - _LOGGER, - name="Nice G.O.", - ) + super().__init__(hass, _LOGGER, config_entry=config_entry, name="Nice G.O.") self.refresh_token = self.config_entry.data[CONF_REFRESH_TOKEN] self.refresh_token_creation_time = self.config_entry.data[ @@ -154,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): ) try: if datetime.now().timestamp() >= expiry_time: - await self._update_refresh_token() + await self.update_refresh_token() else: await self.api.authenticate_refresh( self.refresh_token, async_get_clientsession(self.hass) @@ -179,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): else: self.async_set_updated_data(devices) - async def _update_refresh_token(self) -> None: + async def update_refresh_token(self) -> None: """Update the refresh token with Nice G.O. API.""" _LOGGER.debug("Updating the refresh token with Nice G.O. API") try: diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index 6360e398b96..b9b39711a01 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -2,21 +2,17 @@ from typing import Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NiceGOConfigEntry -from .const import DOMAIN +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry DEVICE_CLASSES = { "WallStation": CoverDeviceClass.GARAGE, @@ -29,7 +25,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. cover.""" coordinator = config_entry.runtime_data @@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity): """Return if cover is closing.""" return self.data.barrier_status == "closing" + @retry("close_cover_error") async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" if self.is_closed: return - try: - await self.coordinator.api.close_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="close_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.close_barrier(self._device_id) + @retry("open_cover_error") async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" if self.is_opened: return - try: - await self.coordinator.api.open_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="open_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.open_barrier(self._device_id) diff --git a/homeassistant/components/nice_go/diagnostics.py b/homeassistant/components/nice_go/diagnostics.py index 2c9a695d4b5..2a663d8925a 100644 --- a/homeassistant/components/nice_go/diagnostics.py +++ b/homeassistant/components/nice_go/diagnostics.py @@ -9,8 +9,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import NiceGOConfigEntry from .const import CONF_REFRESH_TOKEN +from .coordinator import NiceGOConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_EMAIL, CONF_REFRESH_TOKEN, "title", "unique_id"} diff --git a/homeassistant/components/nice_go/event.py b/homeassistant/components/nice_go/event.py index cd9198bcd26..400cc3d2144 100644 --- a/homeassistant/components/nice_go/event.py +++ b/homeassistant/components/nice_go/event.py @@ -5,9 +5,9 @@ from typing import Any from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NiceGOConfigEntry +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity _LOGGER = logging.getLogger(__name__) @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. event.""" diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index abb192adde1..bf283ed6eff 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -3,23 +3,19 @@ import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.light import ColorMode, LightEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NiceGOConfigEntry from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -27,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. light.""" @@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity): assert self.data.light_status is not None return self.data.light_status + @retry("light_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - try: - await self.coordinator.api.light_on(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_on(self._device_id) + @retry("light_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - try: - await self.coordinator.api.light_off(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_off(self._device_id) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 1af23ec4d9b..8f43ed8a3e8 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==1.0.0"] + "requirements": ["nice-go==1.0.1"] } diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index e3b85528f3b..f043a23eab5 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -5,23 +5,19 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NiceGOConfigEntry from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -29,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. switch.""" coordinator = config_entry.runtime_data @@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity): assert self.data.vacation_mode is not None return self.data.vacation_mode + @retry("switch_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - try: - await self.coordinator.api.vacation_mode_on(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_on(self.data.id) + @retry("switch_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - try: - await self.coordinator.api.vacation_mode_off(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_off(self.data.id) diff --git a/homeassistant/components/nice_go/util.py b/homeassistant/components/nice_go/util.py new file mode 100644 index 00000000000..02dee6b0ac1 --- /dev/null +++ b/homeassistant/components/nice_go/util.py @@ -0,0 +1,66 @@ +"""Utilities for Nice G.O.""" + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, Protocol, runtime_checkable + +from aiohttp import ClientError +from nice_go import ApiError, AuthFailedError + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import DOMAIN + + +@runtime_checkable +class _ArgsProtocol(Protocol): + coordinator: Any + hass: Any + + +def retry[_R, **P]( + translation_key: str, +) -> Callable[ + [Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]] +]: + """Retry decorator to handle API errors.""" + + def decorator( + func: Callable[P, Coroutine[Any, Any, _R]], + ) -> Callable[P, Coroutine[Any, Any, _R]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs): + instance = args[0] + if not isinstance(instance, _ArgsProtocol): + raise TypeError("First argument must have correct attributes") + try: + return await func(*args, **kwargs) + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except AuthFailedError: + # Try refreshing token and retry + try: + await instance.coordinator.update_refresh_token() + return await func(*args, **kwargs) + except (ApiError, ClientError, UpdateFailed) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except (AuthFailedError, ConfigEntryAuthFailed) as err: + instance.coordinator.config_entry.async_start_reauth(instance.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + + return wrapper + + return decorator diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 620349ec3c3..de1dadf1143 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN @@ -27,7 +27,7 @@ DEFAULT_NAME = "Blood Glucose" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Glucose Sensor.""" api = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py index ae4e8986816..37396e69caa 100644 --- a/homeassistant/components/niko_home_control/__init__.py +++ b/homeassistant/components/niko_home_control/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from nclib.errors import NetcatError from nhc.controller import NHCController from homeassistant.config_entries import ConfigEntry @@ -25,12 +24,8 @@ async def async_setup_entry( controller = NHCController(entry.data[CONF_HOST]) try: await controller.connect() - except NetcatError as err: + except (TimeoutError, OSError) as err: raise ConfigEntryNotReady("cannot connect to controller.") from err - except OSError as err: - raise ConfigEntryNotReady( - "unknown error while connecting to controller." - ) from err entry.runtime_data = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py index 51e2a8a702d..2ab3438c4d9 100644 --- a/homeassistant/components/niko_home_control/cover.py +++ b/homeassistant/components/niko_home_control/cover.py @@ -8,7 +8,7 @@ from nhc.cover import NHCCover from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NikoHomeControlConfigEntry from .entity import NikoHomeControlEntity @@ -17,7 +17,7 @@ from .entity import NikoHomeControlEntity async def async_setup_entry( hass: HomeAssistant, entry: NikoHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Niko Home Control cover entry.""" controller = entry.runtime_data @@ -37,17 +37,17 @@ class NikoHomeControlCover(NikoHomeControlEntity, CoverEntity): ) _action: NHCCover - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._action.open() + await self._action.open() - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._action.close() + await self._action.close() - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._action.stop() + await self._action.stop() def update_state(self): """Update HA state.""" diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 69d4e71c755..b0a2d12b004 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -18,9 +18,11 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -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 import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import NHCController, NikoHomeControlConfigEntry @@ -81,7 +83,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: NikoHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Niko Home Control light entry.""" controller = entry.runtime_data @@ -110,13 +112,13 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity): self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._attr_brightness = round(action.state * 2.55) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) + await self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55)) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - self._action.turn_off() + await self._action.turn_off() def update_state(self) -> None: """Handle updates from the controller.""" diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index d252a11b38e..83fca0ca2d6 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.3.2"] + "requirements": ["nhc==0.4.10"] } diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 7600a878548..31259349dea 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -34,7 +34,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index d5b1c5ccb35..b02d6711e74 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -11,7 +11,6 @@ from .const import ( CONF_AREA_FILTER, CONF_FILTER_CORONA, CONF_HEADLINE_FILTER, - CONF_REGIONS, DOMAIN, NO_MATCH_REGEX, ) @@ -22,9 +21,6 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" - - regions: dict[str, str] = entry.data[CONF_REGIONS] - if CONF_HEADLINE_FILTER not in entry.data: filter_regex = NO_MATCH_REGEX @@ -39,12 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data = {**entry.data, CONF_AREA_FILTER: ALL_MATCH_REGEX} hass.config_entries.async_update_entry(entry, data=new_data) - coordinator = NINADataUpdateCoordinator( - hass, - regions, - entry.data[CONF_HEADLINE_FILTER], - entry.data[CONF_AREA_FILTER], - ) + coordinator = NINADataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 10d3008fd82..3f7d496aca9 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -36,7 +36,7 @@ from .coordinator import NINADataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entries.""" diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index a1ba9ae0c61..24c016e5e64 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -14,9 +14,8 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 2d9548f3d12..3c27729ef09 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -9,11 +9,19 @@ from typing import Any from pynina import ApiError, Nina +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, DOMAIN, SCAN_INTERVAL +from .const import ( + _LOGGER, + CONF_AREA_FILTER, + CONF_HEADLINE_FILTER, + CONF_REGIONS, + DOMAIN, + SCAN_INTERVAL, +) @dataclass @@ -39,23 +47,29 @@ class NINADataUpdateCoordinator( ): """Class to manage fetching NINA data API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - regions: dict[str, str], - headline_filter: str, - area_filter: str, + config_entry: ConfigEntry, ) -> None: """Initialize.""" - self._regions: dict[str, str] = regions self._nina: Nina = Nina(async_get_clientsession(hass)) - self.headline_filter: str = headline_filter - self.area_filter: str = area_filter + self.headline_filter: str = config_entry.data[CONF_HEADLINE_FILTER] + self.area_filter: str = config_entry.data[CONF_AREA_FILTER] + regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: self._nina.addRegion(region) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: """Update data.""" diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 865ae33b38c..4f24cde0578 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -18,7 +18,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/nissan_leaf/strings.json b/homeassistant/components/nissan_leaf/strings.json index d733e39a0fc..78335ab4c14 100644 --- a/homeassistant/components/nissan_leaf/strings.json +++ b/homeassistant/components/nissan_leaf/strings.json @@ -2,17 +2,17 @@ "services": { "start_charge": { "name": "Start charge", - "description": "Starts the vehicle charging. It must be plugged in first!\n.", + "description": "Starts the vehicle charging. It must be plugged in first!", "fields": { "vin": { "name": "VIN", - "description": "The vehicle identification number (VIN) of the vehicle, 17 characters\n." + "description": "The vehicle identification number (VIN) of the vehicle, 17 characters." } } }, "update": { "name": "Update", - "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.\n.", + "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.", "fields": { "vin": { "name": "[%key:component::nissan_leaf::services::start_charge::fields::vin::name%]", diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dcb4e1361fd..72bf9284573 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -21,12 +21,11 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_HOME_INTERVAL, diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index e05150995aa..1f436edd60c 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index c8e7e7c25ea..afac3f06435 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update from .const import DOMAIN @@ -18,7 +18,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Nmap Tracker component.""" nmap_tracker = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 5b2dab50812..06f94e0566f 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.5", "aiooui==0.1.9"] } diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index ef660c7e991..3cbbea007b1 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -21,7 +21,7 @@ "config": { "step": { "user": { - "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32).", + "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP addresses (192.168.1.1), IP networks (192.168.0.0/24) or IP ranges (192.168.1.0-32).", "data": { "hosts": "Network addresses (comma separated) to scan", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", @@ -31,7 +31,7 @@ } }, "error": { - "invalid_hosts": "Invalid Hosts" + "invalid_hosts": "Invalid hosts" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 11013d471b5..4a2783143ca 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -1 +1,46 @@ -"""The nmbs component.""" +"""The NMBS component.""" + +import logging + +from pyrail import iRail + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] + + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the NMBS component.""" + + api_client = iRail(session=async_get_clientsession(hass)) + + hass.data.setdefault(DOMAIN, {}) + station_response = await api_client.get_stations() + if station_response is None: + return False + hass.data[DOMAIN] = station_response.stations + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NMBS from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py new file mode 100644 index 00000000000..60ab015e22b --- /dev/null +++ b/homeassistant/components/nmbs/config_flow.py @@ -0,0 +1,182 @@ +"""Config flow for NMBS integration.""" + +from typing import Any + +from pyrail import iRail +from pyrail.models import StationDetails +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import Platform +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_EXCLUDE_VIAS, + CONF_SHOW_ON_MAP, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) + + +class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): + """NMBS config flow.""" + + def __init__(self) -> None: + """Initialize.""" + self.stations: list[StationDetails] = [] + + async def _fetch_stations(self) -> list[StationDetails]: + """Fetch the stations.""" + api_client = iRail(session=async_get_clientsession(self.hass)) + stations_response = await api_client.get_stations() + if stations_response is None: + raise CannotConnect("The API is currently unavailable.") + return stations_response.stations + + async def _fetch_stations_choices(self) -> list[SelectOptionDict]: + """Fetch the stations options.""" + + if len(self.stations) == 0: + self.stations = await self._fetch_stations() + + return [ + SelectOptionDict(value=station.id, label=station.standard_name) + for station in self.stations + ] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to setup a connection between 2 stations.""" + + try: + choices = await self._fetch_stations_choices() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + errors: dict = {} + if user_input is not None: + if user_input[CONF_STATION_FROM] == user_input[CONF_STATION_TO]: + errors["base"] = "same_station" + else: + [station_from] = [ + station + for station in self.stations + if station.id == user_input[CONF_STATION_FROM] + ] + [station_to] = [ + station + for station in self.stations + if station.id == user_input[CONF_STATION_TO] + ] + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" + await self.async_set_unique_id( + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}{vias}" + ) + self._abort_if_unique_id_configured() + + config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}" + return self.async_create_entry( + title=config_entry_name, + data=user_input, + ) + + schema = vol.Schema( + { + vol.Required(CONF_STATION_FROM): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_STATION_TO): SelectSelector( + SelectSelectorConfig( + options=choices, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_EXCLUDE_VIAS): BooleanSelector(), + vol.Optional(CONF_SHOW_ON_MAP): BooleanSelector(), + }, + ) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Import configuration from yaml.""" + try: + self.stations = await self._fetch_stations() + except CannotConnect: + return self.async_abort(reason="api_unavailable") + + station_from = None + station_to = None + station_live = None + for station in self.stations: + if user_input[CONF_STATION_FROM] in ( + station.standard_name, + station.name, + ): + station_from = station + if user_input[CONF_STATION_TO] in ( + station.standard_name, + station.name, + ): + station_to = station + if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( + station.standard_name, + station.name, + ): + station_live = station + + if station_from is None or station_to is None: + return self.async_abort(reason="invalid_station") + if station_from == station_to: + return self.async_abort(reason="same_station") + + # config flow uses id and not the standard name + user_input[CONF_STATION_FROM] = station_from.id + user_input[CONF_STATION_TO] = station_to.id + + if station_live: + user_input[CONF_STATION_LIVE] = station_live.id + entity_registry = er.async_get(self.hass) + prefix = "live" + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + if entity_id := entity_registry.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"{prefix}_{station_live.name}_{station_from.name}_{station_to.name}", + ): + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" + entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + + return await self.async_step_user(user_input) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect to NMBS.""" diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py new file mode 100644 index 00000000000..04c8beb327d --- /dev/null +++ b/homeassistant/components/nmbs/const.py @@ -0,0 +1,32 @@ +"""The NMBS integration.""" + +from typing import Final + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +DOMAIN: Final = "nmbs" + +PLATFORMS: Final = [Platform.SENSOR] + +CONF_STATION_FROM = "station_from" +CONF_STATION_TO = "station_to" +CONF_STATION_LIVE = "station_live" +CONF_EXCLUDE_VIAS = "exclude_vias" +CONF_SHOW_ON_MAP = "show_on_map" + + +def find_station_by_name(hass: HomeAssistant, station_name: str): + """Find given station_name in the station list.""" + return next( + (s for s in hass.data[DOMAIN] if station_name in (s.standard_name, s.name)), + None, + ) + + +def find_station(hass: HomeAssistant, station_name: str): + """Find given station_id in the station list.""" + return next( + (s for s in hass.data[DOMAIN] if station_name in s.id), + None, + ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index e17d1227bed..37ff9429a54 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -1,10 +1,11 @@ { "domain": "nmbs", "name": "NMBS", - "codeowners": ["@thibmaek"], + "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nmbs", "iot_class": "cloud_polling", "loggers": ["pyrail"], "quality_scale": "legacy", - "requirements": ["pyrail==0.0.3"] + "requirements": ["pyrail==0.4.1"] } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 6ccdc742430..822b0236dd0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -2,42 +2,56 @@ from __future__ import annotations +from datetime import datetime import logging +from typing import Any from pyrail import iRail +from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, + CONF_PLATFORM, CONF_SHOW_ON_MAP, UnitOfTime, ) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util + +from .const import ( # noqa: F401 + CONF_EXCLUDE_VIAS, + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, + PLATFORMS, + find_station, + find_station_by_name, +) _LOGGER = logging.getLogger(__name__) -API_FAILURE = -1 - DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" DEFAULT_ICON_ALERT = "mdi:alert-octagon" -CONF_STATION_FROM = "station_from" -CONF_STATION_TO = "station_to" -CONF_STATION_LIVE = "station_live" -CONF_EXCLUDE_VIAS = "exclude_vias" - PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_STATION_FROM): cv.string, @@ -50,12 +64,12 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( ) -def get_time_until(departure_time=None): +def get_time_until(departure_time: datetime | None = None): """Calculate the time between now and a train's departure time.""" if departure_time is None: return 0 - delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now() + delta = dt_util.as_utc(departure_time) - dt_util.utcnow() return round(delta.total_seconds() / 60) @@ -64,42 +78,106 @@ def get_delay_in_minutes(delay=0): return round(int(delay) / 60) -def get_ride_duration(departure_time, arrival_time, delay=0): +def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0): """Calculate the total travel time in minutes.""" - duration = dt_util.utc_from_timestamp( - int(arrival_time) - ) - dt_util.utc_from_timestamp(int(departure_time)) + duration = arrival_time - departure_time duration_time = int(round(duration.total_seconds() / 60)) return duration_time + get_delay_in_minutes(delay) -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 NMBS sensor with iRail API.""" - api_client = iRail() + if config[CONF_PLATFORM] == DOMAIN: + if CONF_SHOW_ON_MAP not in config: + config[CONF_SHOW_ON_MAP] = False + if CONF_EXCLUDE_VIAS not in config: + config[CONF_EXCLUDE_VIAS] = False - name = config[CONF_NAME] - show_on_map = config[CONF_SHOW_ON_MAP] - station_from = config[CONF_STATION_FROM] - station_to = config[CONF_STATION_TO] - station_live = config.get(CONF_STATION_LIVE) - excl_vias = config[CONF_EXCLUDE_VIAS] + station_types = [CONF_STATION_FROM, CONF_STATION_TO, CONF_STATION_LIVE] - sensors: list[SensorEntity] = [ - NMBSSensor(api_client, name, show_on_map, station_from, station_to, excl_vias) - ] + for station_type in station_types: + station = ( + find_station_by_name(hass, config[station_type]) + if station_type in config + else None + ) + if station is None and station_type in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_station_not_found", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_station_not_found", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + "station_name": config[station_type], + "url": "/config/integrations/dashboard/add?domain=nmbs", + }, + ) + return - if station_live is not None: - sensors.append( - NMBSLiveBoard(api_client, station_live, station_from, station_to) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - add_entities(sensors, True) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NMBS", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NMBS sensor entities based on a config entry.""" + api_client = iRail(session=async_get_clientsession(hass)) + + name = config_entry.data.get(CONF_NAME, None) + show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) + excl_vias = config_entry.data.get(CONF_EXCLUDE_VIAS, False) + + station_from = find_station(hass, config_entry.data[CONF_STATION_FROM]) + station_to = find_station(hass, config_entry.data[CONF_STATION_TO]) + + # setup the connection from station to station + # setup a disabled liveboard for both from and to station + async_add_entities( + [ + NMBSSensor( + api_client, name, show_on_map, station_from, station_to, excl_vias + ), + NMBSLiveBoard( + api_client, station_from, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_to, station_from, station_to, excl_vias), + ] + ) class NMBSLiveBoard(SensorEntity): @@ -107,55 +185,68 @@ class NMBSLiveBoard(SensorEntity): _attr_attribution = "https://api.irail.be/" - def __init__(self, api_client, live_station, station_from, station_to): + def __init__( + self, + api_client: iRail, + live_station: StationDetails, + station_from: StationDetails, + station_to: StationDetails, + excl_vias: bool, + ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to - self._attrs = {} - self._state = None + + self._excl_vias = excl_vias + self._attrs: LiveboardDeparture | None = None + + self._state: str | None = None + + self.entity_registry_enabled_default = False @property - def name(self): + def name(self) -> str: """Return the sensor default name.""" - return f"NMBS Live ({self._station})" + return f"Trains in {self._station.standard_name}" @property - def unique_id(self): - """Return a unique ID.""" - unique_id = f"{self._station}_{self._station_from}_{self._station_to}" + def unique_id(self) -> str: + """Return the unique ID.""" - return f"nmbs_live_{unique_id}" + unique_id = f"{self._station.id}_{self._station_from.id}_{self._station_to.id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_live_{unique_id}{vias}" @property - def icon(self): + def icon(self) -> str: """Return the default icon or an alert icon if delays.""" - if self._attrs and int(self._attrs["delay"]) > 0: + if self._attrs and int(self._attrs.delay) > 0: return DEFAULT_ICON_ALERT return DEFAULT_ICON @property - def native_value(self): + def native_value(self) -> str | None: """Return sensor state.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the sensor attributes if data is available.""" if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["delay"]) - departure = get_time_until(self._attrs["time"]) + delay = get_delay_in_minutes(self._attrs.delay) + departure = get_time_until(self._attrs.time) attrs = { "departure": f"In {departure} minutes", "departure_minutes": departure, - "extra_train": int(self._attrs["isExtra"]) > 0, - "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station, + "extra_train": self._attrs.is_extra, + "vehicle_id": self._attrs.vehicle, + "monitored_station": self._station.standard_name, } if delay > 0: @@ -164,28 +255,26 @@ class NMBSLiveBoard(SensorEntity): return attrs - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station) + liveboard = await self._api_client.get_liveboard(self._station.id) - if liveboard == API_FAILURE: + if liveboard is None: _LOGGER.warning("API failed in NMBSLiveBoard") return - if not (departures := liveboard.get("departures")): + if not (departures := liveboard.departures): _LOGGER.warning("API returned invalid departures: %r", liveboard) return _LOGGER.debug("API returned departures: %r", departures) - if departures["number"] == "0": + if len(departures) == 0: # No trains are scheduled return - next_departure = departures["departure"][0] + next_departure = departures[0] self._attrs = next_departure - self._state = ( - f"Track {next_departure['platform']} - {next_departure['station']}" - ) + self._state = f"Track {next_departure.platform} - {next_departure.station}" class NMBSSensor(SensorEntity): @@ -195,8 +284,14 @@ class NMBSSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfTime.MINUTES def __init__( - self, api_client, name, show_on_map, station_from, station_to, excl_vias - ): + self, + api_client: iRail, + name: str, + show_on_map: bool, + station_from: StationDetails, + station_to: StationDetails, + excl_vias: bool, + ) -> None: """Initialize the NMBS connection sensor.""" self._name = name self._show_on_map = show_on_map @@ -205,43 +300,53 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs = {} + self._attrs: ConnectionDetails | None = None self._state = None @property - def name(self): + def unique_id(self) -> str: + """Return the unique ID.""" + unique_id = f"{self._station_from.id}_{self._station_to.id}" + + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_connection_{unique_id}{vias}" + + @property + def name(self) -> str: """Return the name of the sensor.""" + if self._name is None: + return f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}" return self._name @property - def icon(self): + def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) if delay > 0: return "mdi:alert-octagon" return "mdi:train" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return sensor attributes if data is available.""" if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) - departure = get_time_until(self._attrs["departure"]["time"]) - canceled = int(self._attrs["departure"]["canceled"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) + departure = get_time_until(self._attrs.departure.time) + canceled = self._attrs.departure.canceled attrs = { - "destination": self._station_to, - "direction": self._attrs["departure"]["direction"]["name"], - "platform_arriving": self._attrs["arrival"]["platform"], - "platform_departing": self._attrs["departure"]["platform"], - "vehicle_id": self._attrs["departure"]["vehicle"], + "destination": self._attrs.departure.station, + "direction": self._attrs.departure.direction.name, + "platform_arriving": self._attrs.arrival.platform, + "platform_departing": self._attrs.departure.platform, + "vehicle_id": self._attrs.departure.vehicle, } - if canceled != 1: + if not canceled: attrs["departure"] = f"In {departure} minutes" attrs["departure_minutes"] = departure attrs["canceled"] = False @@ -255,14 +360,14 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs["vias"]["via"][0] + via = self._attrs.vias.via[0] - attrs["via"] = via["station"] - attrs["via_arrival_platform"] = via["arrival"]["platform"] - attrs["via_transfer_platform"] = via["departure"]["platform"] + attrs["via"] = via.station + attrs["via_arrival_platform"] = via.arrival.platform + attrs["via_transfer_platform"] = via.departure.platform attrs["via_transfer_time"] = get_delay_in_minutes( - via["timebetween"] - ) + get_delay_in_minutes(via["departure"]["delay"]) + via.timebetween + ) + get_delay_in_minutes(via.departure.delay) if delay > 0: attrs["delay"] = f"{delay} minutes" @@ -271,44 +376,44 @@ class NMBSSensor(SensorEntity): return attrs @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the device.""" return self._state @property - def station_coordinates(self): + def station_coordinates(self) -> list[float]: """Get the lat, long coordinates for station.""" if self._state is None or not self._attrs: return [] - latitude = float(self._attrs["departure"]["stationinfo"]["locationY"]) - longitude = float(self._attrs["departure"]["stationinfo"]["locationX"]) + latitude = float(self._attrs.departure.station_info.latitude) + longitude = float(self._attrs.departure.station_info.longitude) return [latitude, longitude] @property - def is_via_connection(self): + def is_via_connection(self) -> bool: """Return whether the connection goes through another station.""" if not self._attrs: return False - return "vias" in self._attrs and int(self._attrs["vias"]["number"]) > 0 + return self._attrs.vias is not None and len(self._attrs.vias) > 0 - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state to the duration of a connection.""" - connections = self._api_client.get_connections( - self._station_from, self._station_to + connections = await self._api_client.get_connections( + self._station_from.id, self._station_to.id ) - if connections == API_FAILURE: + if connections is None: _LOGGER.warning("API failed in NMBSSensor") return - if not (connection := connections.get("connection")): + if not (connection := connections.connections): _LOGGER.warning("API returned invalid connection: %r", connections) return _LOGGER.debug("API returned connection: %r", connection) - if int(connection[0]["departure"]["left"]) > 0: + if connection[0].departure.left: next_connection = connection[1] else: next_connection = connection[0] @@ -322,9 +427,9 @@ class NMBSSensor(SensorEntity): return duration = get_ride_duration( - next_connection["departure"]["time"], - next_connection["arrival"]["time"], - next_connection["departure"]["delay"], + next_connection.departure.time, + next_connection.arrival.time, + next_connection.departure.delay, ) self._state = duration diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json new file mode 100644 index 00000000000..3e7aa8d05bd --- /dev/null +++ b/homeassistant/components/nmbs/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "api_unavailable": "The API is currently unavailable.", + "same_station": "[%key:component::nmbs::config::error::same_station%]", + "invalid_station": "Invalid station." + }, + "error": { + "same_station": "Departure and arrival station can not be the same." + }, + "step": { + "user": { + "data": { + "station_from": "Departure station", + "station_to": "Arrival station", + "exclude_vias": "Direct connections only", + "show_on_map": "Display on map" + }, + "data_description": { + "station_from": "Station where the train departs", + "station_to": "Station where the train arrives", + "exclude_vias": "Exclude connections with transfers", + "show_on_map": "Show the station on the map" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_station_not_found": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index cb02490ac08..c23177ddf94 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/noaa_tides/helpers.py b/homeassistant/components/noaa_tides/helpers.py new file mode 100644 index 00000000000..734cca68f44 --- /dev/null +++ b/homeassistant/components/noaa_tides/helpers.py @@ -0,0 +1,6 @@ +"""Helpers for NOAA Tides integration.""" + + +def get_station_unique_id(station_id: str) -> str: + """Convert a station ID to a unique ID.""" + return f"{station_id.lower()}" diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json index 8cc81857770..02a189883bc 100644 --- a/homeassistant/components/noaa_tides/manifest.json +++ b/homeassistant/components/noaa_tides/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["noaa_coops"], "quality_scale": "legacy", - "requirements": ["noaa-coops==0.1.9"] + "requirements": ["noaa-coops==0.4.0"] } diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index b165478927e..3b5a13b0f15 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -17,11 +17,13 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM +from .helpers import get_station_unique_id + if TYPE_CHECKING: from pandas import Timestamp @@ -105,6 +107,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity): self._unit_system = unit_system self._station = station self.data: NOAATidesData | None = None + self._attr_unique_id = f"{get_station_unique_id(station_id)}_summary" @property def name(self) -> str: @@ -169,8 +172,8 @@ class NOAATidesAndCurrentsSensor(SensorEntity): api_data = df_predictions.head() self.data = NOAATidesData( time_stamp=list(api_data.index), - hi_lo=list(api_data["hi_lo"].values), - predicted_wl=list(api_data["predicted_wl"].values), + hi_lo=list(api_data["type"].values), + predicted_wl=list(api_data["v"].values), ) _LOGGER.debug("Data = %s", api_data) _LOGGER.debug( diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 5b777205c8d..3bbf46f0264 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -7,7 +7,7 @@ from pynobo import nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index a089209cde5..771da420213 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( @@ -46,7 +46,7 @@ MAX_TEMPERATURE = 40 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 43f177dd7a0..c24dbe3d21d 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -10,7 +10,7 @@ from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_HARDWARE_VERSION, @@ -26,7 +26,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 1632b6ba5e7..382fd1b0bf4 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODEL, ATTR_NAME, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER @@ -22,7 +22,7 @@ from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index 30910f8e5f6..c6993826239 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify from . import NordPoolConfigEntry @@ -271,7 +271,7 @@ DAILY_AVERAGE_PRICES_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NordPoolConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nord Pool sensor platform.""" diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 872bd5b1e6b..6607edfdbcb 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -41,7 +41,7 @@ ATTR_CURRENCY = "currency" SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date" SERVICE_GET_PRICES_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_DATE): cv.date, vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]), vol.Optional(ATTR_CURRENCY): vol.All( diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index bba4737550b..36de8c8b1ad 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 0b7a25ced3e..97759db4c13 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -8,14 +8,14 @@ from functools import partial import logging from typing import Any, final, override -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index 29064f24a66..11ce4e801a1 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv ATTR_DATA = "data" diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index e832bfc248a..b33af360448 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -24,7 +24,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } }, @@ -56,7 +56,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } } diff --git a/homeassistant/components/notify_events/__init__.py b/homeassistant/components/notify_events/__init__.py index 2be97d709a9..76cfd9be4ff 100644 --- a/homeassistant/components/notify_events/__init__.py +++ b/homeassistant/components/notify_events/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 8c57310752a..5552305e867 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -107,7 +107,9 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/notion/coordinator.py b/homeassistant/components/notion/coordinator.py index c3fd23abc84..d77bfa95f47 100644 --- a/homeassistant/components/notion/coordinator.py +++ b/homeassistant/components/notion/coordinator.py @@ -117,16 +117,16 @@ class NotionDataUpdateCoordinator(DataUpdateCoordinator[NotionData]): super().__init__( hass, LOGGER, + config_entry=entry, name=entry.data[CONF_USERNAME], update_interval=DEFAULT_SCAN_INTERVAL, ) self._client = client - self._entry = entry async def _async_update_data(self) -> NotionData: """Fetch data from Notion.""" - data = NotionData(hass=self.hass, entry=self._entry) + data = NotionData(hass=self.hass, entry=self.config_entry) try: async with asyncio.TaskGroup() as tg: diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index fb853e65d7d..24496c8391a 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .coordinator import NotionDataUpdateCoordinator @@ -42,7 +42,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index f99790664da..7ae9b3a4d9f 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_CENT, UnitOfVolume from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 8248c1b9b82..376a07ddb7b 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event as event_helper from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY @@ -55,7 +55,7 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NuHeat thermostat(s).""" thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 4f3f56f7f03..5c02b6e972e 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -222,7 +222,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) ) - coordinator = NukiCoordinator(hass, bridge, locks, openers) + coordinator = NukiCoordinator(hass, entry, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = NukiEntryData( coordinator=coordinator, bridge=bridge, diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 8269c43813e..2785c46ca17 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData from .const import DOMAIN as NUKI_DOMAIN @@ -20,7 +20,9 @@ from .entity import NukiEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki binary sensors.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 4a9789c7e51..ac6771bb1bd 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -9,10 +9,10 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id @@ -75,7 +75,7 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_validate(user_input) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Nuki bridge.""" await self.async_set_unique_id(discovery_info.hostname[12:].upper()) diff --git a/homeassistant/components/nuki/coordinator.py b/homeassistant/components/nuki/coordinator.py index 114b4aee4c9..cccff99e397 100644 --- a/homeassistant/components/nuki/coordinator.py +++ b/homeassistant/components/nuki/coordinator.py @@ -12,6 +12,7 @@ from pynuki.bridge import InvalidCredentialsException from pynuki.device import NukiDevice from requests.exceptions import RequestException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,9 +29,12 @@ UPDATE_INTERVAL = timedelta(seconds=30) class NukiCoordinator(DataUpdateCoordinator[None]): """Data Update Coordinator for the Nuki integration.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, bridge: NukiBridge, locks: list[NukiLock], openers: list[NukiOpener], @@ -39,9 +43,8 @@ class NukiCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, - # Name of the data. For logging purposes. + config_entry=config_entry, name="nuki devices", - # Polling interval. Will only be polled if there are subscribers. update_interval=UPDATE_INTERVAL, ) self.bridge = bridge diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index a2bf7559fc4..3cc972d3555 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -15,7 +15,7 @@ from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN as NUKI_DOMAIN, ERROR_STATES @@ -24,7 +24,9 @@ from .helpers import CannotConnect async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock platform.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index d89202ac7d7..4f3890a10cf 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData from .const import DOMAIN as NUKI_DOMAIN @@ -16,7 +16,9 @@ from .entity import NukiEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock sensor.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index beac3cb7f74..daf47bc7de1 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -58,12 +58,12 @@ }, "services": { "lock_n_go": { - "name": "Lock 'n' go", - "description": "Nuki Lock 'n' Go.", + "name": "Lock 'n' Go", + "description": "Unlocks the door, waits a few seconds then re-locks. The wait period can be customized through the app.", "fields": { "unlatch": { "name": "Unlatch", - "description": "Whether to unlatch the lock." + "description": "Whether to also unlatch the door when unlocking it." } } }, diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 00122132d44..d3882bea290 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 9f4aef08aa9..3e9d3448af2 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -10,7 +10,7 @@ import logging from math import ceil, floor from typing import TYPE_CHECKING, Any, Self, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -68,8 +68,8 @@ __all__ = [ "DEFAULT_MIN_VALUE", "DEFAULT_STEP", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "NumberDeviceClass", "NumberEntity", "NumberEntityDescription", diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 91a9d6adfe4..61a4fa644b0 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + DEGREE, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -23,6 +24,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -166,6 +168,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -363,7 +374,7 @@ class NumberDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" @@ -414,6 +425,12 @@ class NumberDeviceClass(StrEnum): - USCS / imperial: `oz`, `lb` """ + WIND_DIRECTION = "wind_direction" + """Wind direction. + + Unit of measurement: `°` + """ + WIND_SPEED = "wind_speed" """Wind speed. @@ -447,6 +464,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { @@ -483,7 +501,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { SIGNAL_STRENGTH_DECIBELS_MILLIWATT, }, NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure), - NumberDeviceClass.SPEED: set(UnitOfSpeed).union(set(UnitOfVolumetricFlux)), + NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux}, NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature), NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { @@ -505,6 +523,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.LITERS, }, NumberDeviceClass.WEIGHT: set(UnitOfMass), + NumberDeviceClass.WIND_DIRECTION: {DEGREE}, NumberDeviceClass.WIND_SPEED: set(UnitOfSpeed), } diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 8882bb22a0d..6dd85e000bd 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 636fa0a7751..49103f5cd41 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -150,6 +150,9 @@ "weight": { "default": "mdi:weight" }, + "wind_direction": { + "default": "mdi:compass-rose" + }, "wind_speed": { "default": "mdi:weather-windy" } diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index cc77d224d72..993120ef3ad 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -169,6 +169,9 @@ "weight": { "name": "[%key:component::sensor::entity_component::weight::name%]" }, + "wind_direction": { + "name": "[%key:component::sensor::entity_component::wind_direction::name%]" + }, "wind_speed": { "name": "[%key:component::sensor::entity_component::wind_speed::name%]" } diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 966c51e98e9..b1b44966d14 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from aionut import NUTError, NUTLoginError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -27,6 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import PyNUTData from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -95,7 +95,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry: ConfigEntry | None = None async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a discovered nut device.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 6db40a910a0..924c591e783 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -63,6 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" +COMMAND_OUTLET1_OFF = "outlet.1.load.off" +COMMAND_OUTLET1_ON = "outlet.1.load.on" +COMMAND_OUTLET2_OFF = "outlet.2.load.off" +COMMAND_OUTLET2_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -91,4 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, + COMMAND_OUTLET1_OFF, + COMMAND_OUTLET1_ON, + COMMAND_OUTLET2_OFF, + COMMAND_OUTLET2_ON, } diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index a051f843226..ffaa195deaf 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import NutRuntimeData diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 532e4ece76b..ec59fa65c22 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics( hass_device = device_registry.async_get_device( identifiers={(DOMAIN, hass_data.unique_id)} ) - if not hass_device: - return data + # Device is always created + assert hass_device is not None data["device"] = { **attr.asdict(hass_device), diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index e0f78d6400b..91df9d10553 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "ambient_humidity_status": { + "default": "mdi:information-outline" + }, + "ambient_temperature_status": { + "default": "mdi:information-outline" + }, "battery_alarm_threshold": { "default": "mdi:information-outline" }, diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 9e968b5a349..1ee85a84caf 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -1,12 +1,12 @@ { "domain": "nut", "name": "Network UPS Tools (NUT)", - "codeowners": ["@bdraco", "@ollo69", "@pestevez"], + "codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nut", "integration_type": "device", "iot_class": "local_polling", "loggers": ["aionut"], - "requirements": ["aionut==4.3.3"], + "requirements": ["aionut==4.3.4"], "zeroconf": ["_nut._tcp.local."] } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index bb702873052..2f574ec4842 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -46,8 +46,17 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "serial": ATTR_SERIAL_NUMBER, } +AMBIENT_THRESHOLD_STATUS_OPTIONS = [ + "good", + "warning-low", + "critical-low", + "warning-high", + "critical-high", +] + _LOGGER = logging.getLogger(__name__) + SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", @@ -930,6 +939,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", translation_key="ambient_temperature", @@ -938,6 +954,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "watts": SensorEntityDescription( key="watts", translation_key="watts", @@ -963,7 +986,7 @@ def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: async def async_setup_entry( hass: HomeAssistant, config_entry: NutConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NUT sensors.""" diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index ec5905fc16c..1cd5415b0d6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -74,13 +74,19 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test" + "test_system_start": "Start a system test", + "outlet_1_load_on": "Power outlet 1 on", + "outlet_1_load_off": "Power outlet 1 off", + "outlet_2_load_on": "Power outlet 2 on", + "outlet_2_load_off": "Power outlet 2 off" } }, "entity": { "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, + "ambient_humidity_status": { "name": "Ambient humidity status" }, "ambient_temperature": { "name": "Ambient temperature" }, + "ambient_temperature_status": { "name": "Ambient temperature status" }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, @@ -113,15 +119,15 @@ "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, - "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_l1_realpower": { - "name": "Current input bypass L1 real power" + "name": "Input bypass L1 real power" }, "input_bypass_l2_realpower": { - "name": "Current input bypass L2 real power" + "name": "Input bypass L2 real power" }, "input_bypass_l3_realpower": { - "name": "Current input bypass L3 real power" + "name": "Input bypass L3 real power" }, "input_current": { "name": "Input current" }, "input_l1_current": { "name": "Input L1 current" }, @@ -134,10 +140,10 @@ "input_l2_frequency": { "name": "Input L2 line frequency" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, - "input_realpower": { "name": "Current input real power" }, - "input_l1_realpower": { "name": "Current input L1 real power" }, - "input_l2_realpower": { "name": "Current input L2 real power" }, - "input_l3_realpower": { "name": "Current input L3 real power" }, + "input_realpower": { "name": "Input real power" }, + "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_realpower": { "name": "Input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, @@ -160,11 +166,11 @@ "output_l1_power_percent": { "name": "Output L1 power usage" }, "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, - "output_realpower": { "name": "Current output real power" }, + "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, - "output_l1_realpower": { "name": "Current output L1 real power" }, - "output_l2_realpower": { "name": "Current output L2 real power" }, - "output_l3_realpower": { "name": "Current output L3 real power" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_realpower": { "name": "Output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "output_l1_n_voltage": { "name": "Output L1-N voltage" }, @@ -183,9 +189,9 @@ "ups_id": { "name": "System identifier" }, "ups_load": { "name": "Load" }, "ups_load_high": { "name": "Overload setting" }, - "ups_power": { "name": "Current apparent power" }, + "ups_power": { "name": "Apparent power" }, "ups_power_nominal": { "name": "Nominal power" }, - "ups_realpower": { "name": "Current real power" }, + "ups_realpower": { "name": "Real power" }, "ups_realpower_nominal": { "name": "Nominal real power" }, "ups_shutdown": { "name": "Shutdown ability" }, "ups_start_auto": { "name": "Start on ac" }, diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index c700476ed3d..633619bcf05 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -101,10 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: return update_forecast_hourly - coordinator_observation = NWSObservationDataUpdateCoordinator( - hass, - nws_data, - ) + coordinator_observation = NWSObservationDataUpdateCoordinator(hass, entry, nws_data) # Don't use retries in setup coordinator_forecast = TimestampDataUpdateCoordinator( diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py index 104b1812c67..4e6560947e8 100644 --- a/homeassistant/components/nws/coordinator.py +++ b/homeassistant/components/nws/coordinator.py @@ -1,7 +1,10 @@ """The NWS coordinator.""" +from __future__ import annotations + from datetime import datetime import logging +from typing import TYPE_CHECKING from aiohttp import ClientResponseError from pynws import NwsNoDataError, SimpleNWS, call_with_retry @@ -14,6 +17,9 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util.dt import utcnow +if TYPE_CHECKING: + from . import NWSConfigEntry + from .const import ( DEBOUNCE_TIME, DEFAULT_SCAN_INTERVAL, @@ -29,9 +35,12 @@ _LOGGER = logging.getLogger(__name__) class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): """Class to manage fetching NWS observation data.""" + config_entry: NWSConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: NWSConfigEntry, nws: SimpleNWS, ) -> None: """Initialize.""" @@ -42,6 +51,7 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"NWS observation station {nws.station}", update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index d1992056d47..4cfb3b85e0f 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, TimestampDataUpdateCoordinator, @@ -114,6 +114,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( icon="mdi:compass-rose", native_unit_of_measurement=DEGREE, unit_convert=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NWSSensorEntityDescription( key="barometricPressure", @@ -148,7 +149,9 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NWSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NWS weather platform.""" nws_data = entry.runtime_data diff --git a/homeassistant/components/nws/strings.json b/homeassistant/components/nws/strings.json index c9ee8349631..72b6a2c86b6 100644 --- a/homeassistant/components/nws/strings.json +++ b/homeassistant/components/nws/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, an API Key can be anything. It is recommended to use a valid email address.", + "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, the API key can be anything. It is recommended to use a valid email address.", "title": "Connect to the National Weather Service", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -30,12 +30,12 @@ }, "services": { "get_forecasts_extra": { - "name": "Get extra forecasts data.", - "description": "Get extra data for weather forecasts.", + "name": "Get extra forecasts data", + "description": "Retrieves extra data for weather forecasts.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: hourly or twice_daily." + "description": "The scope of the weather forecast." } } } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 3c7393aa184..c90c67edcb7 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import partial from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast +from typing import Any, Required, TypedDict, cast import voluptuous as vol @@ -40,7 +40,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.json import JsonValueType from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter @@ -87,7 +87,9 @@ def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> async def async_setup_entry( - hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NWSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NWS weather platform.""" entity_registry = er.async_get(hass) @@ -177,8 +179,6 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) for forecast_type in ("twice_daily", "hourly"): if (coordinator := self.forecast_coordinators[forecast_type]) is None: continue - if TYPE_CHECKING: - forecast_type = cast(Literal["twice_daily", "hourly"], forecast_type) self.unsub_forecast[forecast_type] = coordinator.async_add_listener( partial(self._handle_forecast_update, forecast_type) ) diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 04e79716423..69e2f626049 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/nyt_games/__init__.py b/homeassistant/components/nyt_games/__init__.py index 94dc22fe89e..d1c6ca5c2a4 100644 --- a/homeassistant/components/nyt_games/__init__.py +++ b/homeassistant/components/nyt_games/__init__.py @@ -4,21 +4,17 @@ from __future__ import annotations from nyt_games import NYTGamesClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .coordinator import NYTGamesCoordinator +from .coordinator import NYTGamesConfigEntry, NYTGamesCoordinator PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -type NYTGamesConfigEntry = ConfigEntry[NYTGamesCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> bool: """Set up NYTGames from a config entry.""" @@ -26,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> entry.data[CONF_TOKEN], session=async_create_clientsession(hass) ) - coordinator = NYTGamesCoordinator(hass, client) + coordinator = NYTGamesCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py index 5e88a5dd92a..ae9ea4f03a0 100644 --- a/homeassistant/components/nyt_games/coordinator.py +++ b/homeassistant/components/nyt_games/coordinator.py @@ -4,18 +4,15 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING from nyt_games import Connections, NYTGamesClient, NYTGamesError, SpellingBee, Wordle +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER -if TYPE_CHECKING: - from . import NYTGamesConfigEntry - @dataclass class NYTGamesData: @@ -26,16 +23,25 @@ class NYTGamesData: connections: Connections | None +type NYTGamesConfigEntry = ConfigEntry[NYTGamesCoordinator] + + class NYTGamesCoordinator(DataUpdateCoordinator[NYTGamesData]): """Class to manage fetching NYT Games data.""" config_entry: NYTGamesConfigEntry - def __init__(self, hass: HomeAssistant, client: NYTGamesClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: NYTGamesConfigEntry, + client: NYTGamesClient, + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name="NYT Games", update_interval=timedelta(minutes=15), ) diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py index 01b2db4620b..5009eafd85a 100644 --- a/homeassistant/components/nyt_games/sensor.py +++ b/homeassistant/components/nyt_games/sensor.py @@ -14,11 +14,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import NYTGamesConfigEntry -from .coordinator import NYTGamesCoordinator +from .coordinator import NYTGamesConfigEntry, NYTGamesCoordinator from .entity import ConnectionsEntity, SpellingBeeEntity, WordleEntity @@ -147,7 +146,7 @@ CONNECTIONS_SENSORS: tuple[NYTGamesConnectionsSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NYTGamesConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NYT Games sensor entities based on a config entry.""" diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 84456c4c006..e9e5856d524 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -31,10 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" hass.data.setdefault(DOMAIN, {}) - coordinator = NZBGetDataUpdateCoordinator( - hass, - config=entry.data, - ) + coordinator = NZBGetDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index cf9625ce4ec..9e6b06da760 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -1,13 +1,12 @@ """Provides the NZBGet DataUpdateCoordinator.""" import asyncio -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any from pynzbgetapi import NZBGetAPI, NZBGetAPIException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -27,27 +26,32 @@ _LOGGER = logging.getLogger(__name__) class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching NZBGet data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, - config: Mapping[str, Any], + config_entry: ConfigEntry, ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( - config[CONF_HOST], - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config[CONF_SSL], - config[CONF_VERIFY_SSL], - config[CONF_PORT], + config_entry.data[CONF_HOST], + config_entry.data.get(CONF_USERNAME), + config_entry.data.get(CONF_PASSWORD), + config_entry.data[CONF_SSL], + config_entry.data[CONF_VERIFY_SSL], + config_entry.data[CONF_PORT], ) self._completed_downloads_init = False self._completed_downloads = set[tuple]() super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=5) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=5), ) def _check_completed_downloads(self, history): diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index f6a4e4cc973..2328bf453f0 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -93,7 +93,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NZBGet sensor based on a config entry.""" coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index 552a1854902..0796f628507 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_COORDINATOR, DOMAIN from .coordinator import NZBGetDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import NZBGetEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NZBGet sensor based on a config entry.""" coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index fef4cef48af..ddf4942ef25 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index d1b924b4693..9cef92d3fce 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -27,7 +27,7 @@ BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: entity_platform.AddConfigEntryEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 559900db5d0..03f6348ebac 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -8,11 +8,11 @@ from typing import Any from pyobihai import PyObihai import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -54,7 +54,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 2 discovery_schema: vol.Schema | None = None - _dhcp_discovery_info: dhcp.DhcpServiceInfo | None = None + _dhcp_discovery_info: DhcpServiceInfo | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -94,7 +94,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Obihai.""" diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index c162bd6c559..ec29238201a 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -9,7 +9,7 @@ from requests.exceptions import RequestException from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .connectivity import ObihaiConnection from .const import DOMAIN, LOGGER, OBIHAI @@ -18,7 +18,9 @@ SCAN_INTERVAL = datetime.timedelta(seconds=5) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 7a9f3990435..59fd04357eb 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index 10a637e5a3b..a20738de150 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -9,7 +9,7 @@ from pyoctoprintapi import OctoprintPrinterInfo from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -19,7 +19,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint binary sensors.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 2a2e5015303..3a128fcd7aa 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -16,7 +16,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Octoprint control buttons.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index e6430c55fa2..37347539d5b 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -8,7 +8,7 @@ from homeassistant.components.mjpeg import MjpegCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -18,7 +18,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint camera.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 9bbf21d71fa..010b45e5a1c 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -12,7 +12,6 @@ from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, @@ -25,7 +24,9 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN @@ -167,7 +168,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user(import_data) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery flow.""" uuid = discovery_info.properties["uuid"] @@ -193,7 +194,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discovery flow.""" uuid = discovery_info.upnp["UDN"][5:] diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index ff00b6c3420..bb006329ff1 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -39,10 +39,10 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"octoprint-{config_entry.entry_id}", update_interval=timedelta(seconds=interval), ) - self.config_entry = config_entry self._octoprint = octoprint self._printer_offline = False self.data = {"printer": None, "job": None, "last_read_time": None} @@ -80,7 +80,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): """Device info.""" unique_id = cast(str, self.config_entry.unique_id) configuration_url = URL.build( - scheme=self.config_entry.data[CONF_SSL] and "https" or "http", + scheme=(self.config_entry.data[CONF_SSL] and "https") or "http", host=self.config_entry.data[CONF_HOST], port=self.config_entry.data[CONF_PORT], path=self.config_entry.data[CONF_PATH], diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index fb5f292d669..71db1d804c5 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -38,7 +38,7 @@ def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint binary sensors.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 5687ab36033..7f08d04e3da 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -1,11 +1,11 @@ { "config": { - "flow_title": "OctoPrint Printer: {host}", + "flow_title": "OctoPrint printer: {host}", "step": { "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "path": "Application Path", + "path": "Application path", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "auth_failed": "Failed to retrieve application api key", + "auth_failed": "Failed to retrieve API key", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { @@ -44,7 +44,7 @@ "services": { "printer_connect": { "name": "Connect to a printer", - "description": "Instructs the octoprint server to connect to a printer.", + "description": "Instructs the OctoPrint server to connect to a printer.", "fields": { "device_id": { "name": "Server", diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index 4cecb9ff195..e5ccdf6ede8 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index b32db33cc2d..287842178d8 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index 8518e55c0a3..e3e252cbf8b 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -1,10 +1,7 @@ """Set up ohme integration.""" -from dataclasses import dataclass - from ohme import ApiException, AuthException, OhmeApiClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -15,23 +12,14 @@ from .const import DOMAIN, PLATFORMS from .coordinator import ( OhmeAdvancedSettingsCoordinator, OhmeChargeSessionCoordinator, + OhmeConfigEntry, OhmeDeviceInfoCoordinator, + OhmeRuntimeData, ) from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData] - - -@dataclass() -class OhmeRuntimeData: - """Dataclass to hold ohme coordinators.""" - - charge_session_coordinator: OhmeChargeSessionCoordinator - advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator - device_info_coordinator: OhmeDeviceInfoCoordinator - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Ohme integration.""" @@ -62,9 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool ) from e coordinators = ( - OhmeChargeSessionCoordinator(hass, client), - OhmeAdvancedSettingsCoordinator(hass, client), - OhmeDeviceInfoCoordinator(hass, client), + OhmeChargeSessionCoordinator(hass, entry, client), + OhmeAdvancedSettingsCoordinator(hass, entry, client), + OhmeDeviceInfoCoordinator(hass, entry, client), ) for coordinator in coordinators: diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index 0b0590428ce..6e942215c0f 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -10,10 +10,10 @@ from ohme import ApiException, ChargerStatus, OhmeApiClient from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 @@ -40,7 +40,7 @@ BUTTON_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons.""" coordinator = config_entry.runtime_data.charge_session_coordinator diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py index 770d18e823a..d97f6e3cfd7 100644 --- a/homeassistant/components/ohme/const.py +++ b/homeassistant/components/ohme/const.py @@ -3,4 +3,11 @@ from homeassistant.const import Platform DOMAIN = "ohme" -PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py index 199eb7380a7..864b03e9a7c 100644 --- a/homeassistant/components/ohme/coordinator.py +++ b/homeassistant/components/ohme/coordinator.py @@ -1,11 +1,15 @@ """Ohme coordinators.""" +from __future__ import annotations + from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging from ohme import ApiException, OhmeApiClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,18 +18,34 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass() +class OhmeRuntimeData: + """Dataclass to hold ohme coordinators.""" + + charge_session_coordinator: OhmeChargeSessionCoordinator + advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator + device_info_coordinator: OhmeDeviceInfoCoordinator + + +type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData] + + class OhmeBaseCoordinator(DataUpdateCoordinator[None]): """Base for all Ohme coordinators.""" + config_entry: OhmeConfigEntry client: OhmeApiClient _default_update_interval: timedelta | None = timedelta(minutes=1) coordinator_name: str = "" - def __init__(self, hass: HomeAssistant, client: OhmeApiClient) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient + ) -> None: """Initialise coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="", update_interval=self._default_update_interval, ) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 6fa7925aa02..9771b0bf5c2 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -5,6 +5,19 @@ "default": "mdi:check-decagram" } }, + "number": { + "preconditioning_duration": { + "default": "mdi:fan-clock" + }, + "target_percentage": { + "default": "mdi:battery-heart" + } + }, + "select": { + "charge_mode": { + "default": "mdi:play-box" + } + }, "sensor": { "status": { "default": "mdi:car", @@ -18,6 +31,9 @@ }, "ct_current": { "default": "mdi:gauge" + }, + "slot_list": { + "default": "mdi:calendar-clock" } }, "switch": { @@ -36,6 +52,11 @@ "off": "mdi:sleep-off" } } + }, + "time": { + "target_time": { + "default": "mdi:clock-end" + } } }, "services": { diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 935975502d0..fb11fa0dd06 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.3"] + "requirements": ["ohme==1.3.2"] } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py new file mode 100644 index 00000000000..0c71bab009f --- /dev/null +++ b/homeassistant/components/ohme/number.py @@ -0,0 +1,89 @@ +"""Platform for number.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from ohme import ApiException, OhmeApiClient + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import OhmeConfigEntry +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription): + """Class describing Ohme number entities.""" + + set_fn: Callable[[OhmeApiClient, float], Awaitable[None]] + value_fn: Callable[[OhmeApiClient], float] + + +NUMBER_DESCRIPTION = [ + OhmeNumberDescription( + key="target_percentage", + translation_key="target_percentage", + value_fn=lambda client: client.target_soc, + set_fn=lambda client, value: client.async_set_target(target_percent=value), + native_min_value=0, + native_max_value=100, + native_step=1, + native_unit_of_measurement=PERCENTAGE, + ), + OhmeNumberDescription( + key="preconditioning_duration", + translation_key="preconditioning_duration", + value_fn=lambda client: client.preconditioning, + set_fn=lambda client, value: client.async_set_target( + pre_condition_length=value + ), + native_min_value=0, + native_max_value=60, + native_step=5, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up numbers.""" + coordinators = config_entry.runtime_data + coordinator = coordinators.charge_session_coordinator + + async_add_entities( + OhmeNumber(coordinator, description) + for description in NUMBER_DESCRIPTION + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeNumber(OhmeEntity, NumberEntity): + """Generic number entity for Ohme.""" + + entity_description: OhmeNumberDescription + + @property + def native_value(self) -> float: + """Return the current value of the number.""" + return self.entity_description.value_fn(self.coordinator.client) + + async def async_set_native_value(self, value: float) -> None: + """Set the number value.""" + try: + await self.entity_description.set_fn(self.coordinator.client, value) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py new file mode 100644 index 00000000000..17cc7c67e9a --- /dev/null +++ b/homeassistant/components/ohme/select.py @@ -0,0 +1,70 @@ +"""Platform for Ohme selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Final + +from ohme import ApiException, ChargerMode, OhmeApiClient + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import OhmeConfigEntry +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): + """Class to describe an Ohme select entity.""" + + select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + current_option_fn: Callable[[OhmeApiClient], str | None] + + +SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( + key="charge_mode", + translation_key="charge_mode", + select_fn=lambda client, mode: client.async_set_mode(mode), + options=[e.value for e in ChargerMode], + current_option_fn=lambda client: client.mode.value if client.mode else None, + available_fn=lambda client: client.mode is not None, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ohme selects.""" + coordinator = config_entry.runtime_data.charge_session_coordinator + + async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)]) + + +class OhmeSelect(OhmeEntity, SelectEntity): + """Ohme select entity.""" + + entity_description: OhmeSelectDescription + + async def async_select_option(self, option: str) -> None: + """Handle the selection of an option.""" + try: + await self.entity_description.select_fn(self.coordinator.client, option) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self.entity_description.current_option_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 230314cba83..d0425040b53 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -15,14 +15,16 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + STATE_UNKNOWN, UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import OhmeConfigEntry +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 0 @@ -66,6 +68,13 @@ SENSOR_CHARGE_SESSION = [ state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda client: client.energy, ), + OhmeSensorDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client: client.power.volts, + ), OhmeSensorDescription( key="battery", translation_key="vehicle_battery", @@ -74,6 +83,12 @@ SENSOR_CHARGE_SESSION = [ suggested_display_precision=0, value_fn=lambda client: client.battery, ), + OhmeSensorDescription( + key="slot_list", + translation_key="slot_list", + value_fn=lambda client: ", ".join(str(x) for x in client.slots) + or STATE_UNKNOWN, + ), ] SENSOR_ADVANCED_SETTINGS = [ @@ -91,7 +106,7 @@ SENSOR_ADVANCED_SETTINGS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4c45f8eca8c..4c845daa8f0 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -50,15 +50,34 @@ "name": "Approve charge" } }, + "number": { + "preconditioning_duration": { + "name": "Preconditioning duration" + }, + "target_percentage": { + "name": "Target percentage" + } + }, + "select": { + "charge_mode": { + "name": "Charge mode", + "state": { + "smart_charge": "Smart charge", + "max_charge": "Max charge", + "paused": "Paused" + } + } + }, "sensor": { "status": { "name": "Status", "state": { "unplugged": "Unplugged", "plugged_in": "Plugged in", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", - "pending_approval": "Pending approval" + "pending_approval": "Pending approval", + "finished": "Finished charging" } }, "ct_current": { @@ -66,6 +85,9 @@ }, "vehicle_battery": { "name": "Vehicle battery" + }, + "slot_list": { + "name": "Charge slots" } }, "switch": { @@ -78,6 +100,11 @@ "sleep_when_inactive": { "name": "Sleep when inactive" } + }, + "time": { + "target_time": { + "name": "Target time" + } } }, "exceptions": { diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index d1eb1a80b56..c4465ec7e97 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -9,10 +9,10 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 @@ -53,7 +53,7 @@ SWITCH_DEVICE_INFO = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py new file mode 100644 index 00000000000..264b2afd41a --- /dev/null +++ b/homeassistant/components/ohme/time.py @@ -0,0 +1,77 @@ +"""Platform for time.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import time + +from ohme import ApiException, OhmeApiClient + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import OhmeConfigEntry +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription): + """Class describing Ohme time entities.""" + + set_fn: Callable[[OhmeApiClient, time], Awaitable[None]] + value_fn: Callable[[OhmeApiClient], time] + + +TIME_DESCRIPTION = [ + OhmeTimeDescription( + key="target_time", + translation_key="target_time", + value_fn=lambda client: time( + hour=client.target_time[0], minute=client.target_time[1] + ), + set_fn=lambda client, value: client.async_set_target( + target_time=(value.hour, value.minute) + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up time entities.""" + coordinators = config_entry.runtime_data + coordinator = coordinators.charge_session_coordinator + + async_add_entities( + OhmeTime(coordinator, description) + for description in TIME_DESCRIPTION + if description.is_supported_fn(coordinator.client) + ) + + +class OhmeTime(OhmeEntity, TimeEntity): + """Generic time entity for Ohme.""" + + entity_description: OhmeTimeDescription + + @property + def native_value(self) -> time: + """Return the current value of the time.""" + return self.entity_description.value_fn(self.coordinator.client) + + async def async_set_value(self, value: time) -> None: + """Set the time value.""" + try: + await self.entity_description.set_fn(self.coordinator.client, value) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 3bcba567803..6983db73cf4 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -28,12 +28,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) __all__ = [ - "CONF_URL", - "CONF_PROMPT", - "CONF_MODEL", - "CONF_MAX_HISTORY", - "CONF_NUM_CTX", "CONF_KEEP_ALIVE", + "CONF_MAX_HISTORY", + "CONF_MODEL", + "CONF_NUM_CTX", + "CONF_PROMPT", + "CONF_URL", "DOMAIN", ] diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 69c0a3d6296..857f0bff34a 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -61,7 +61,8 @@ MODEL_NAMES = [ # https://ollama.com/library "goliath", "granite-code", "granite3-dense", - "granite3-guardian" "granite3-moe", + "granite3-guardian", + "granite3-moe", "hermes3", "internlm2", "llama-guard3", diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 1a91c790d27..90e81544f66 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,25 +2,21 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable import json import logging -import time from typing import Any, Literal import ollama -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import intent, llm, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import chat_session, intent, llm +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_KEEP_ALIVE, @@ -32,7 +28,6 @@ from .const import ( DEFAULT_MAX_HISTORY, DEFAULT_NUM_CTX, DOMAIN, - MAX_HISTORY_SECONDS, ) from .models import MessageHistory, MessageRole @@ -45,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = OllamaConversationEntity(config_entry) @@ -93,6 +88,84 @@ def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} +def _convert_content( + chat_content: conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent, +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncGenerator[ollama.Message], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk + + class OllamaConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -105,7 +178,6 @@ class OllamaConversationEntity( self.entry = entry # conversation id -> message history - self._history: dict[str, MessageHistory] = {} self._attr_name = entry.title self._attr_unique_id = entry.entry_id if self.entry.options.get(CONF_LLM_HASS_API): @@ -138,208 +210,112 @@ class OllamaConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Call the API.""" settings = {**self.entry.data, **self.entry.options} client = self.hass.data[DOMAIN][self.entry.entry_id] - conversation_id = user_input.conversation_id or ulid.ulid_now() model = settings[CONF_MODEL] - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[dict[str, Any]] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - if settings.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - settings[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - _LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) + try: + await chat_log.async_update_llm_data( + DOMAIN, + user_input, + settings.get(CONF_LLM_HASS_API), + settings.get(CONF_PROMPT), + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[dict[str, Any]] | None = None + if chat_log.llm_api: tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name - - # Look up message history - message_history: MessageHistory | None = None - message_history = self._history.get(conversation_id) - if message_history is None: - # New history - # - # Render prompt and error out early if there's a problem - try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + settings.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - except TemplateError as err: - _LOGGER.error("Error rendering prompt: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem generating my prompt: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - prompt = "\n".join(prompt_parts) - _LOGGER.debug("Prompt: %s", prompt) - _LOGGER.debug("Tools: %s", tools) - - message_history = MessageHistory( - timestamp=time.monotonic(), - messages=[ - ollama.Message(role=MessageRole.SYSTEM.value, content=prompt) - ], - ) - self._history[conversation_id] = message_history - else: - # Bump timestamp so this conversation won't get cleaned up - message_history.timestamp = time.monotonic() - - # Clean up old histories - self._prune_old_histories() - - # Trim this message history to keep a maximum number of *user* messages + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) self._trim_history(message_history, max_messages) - # Add new user message - message_history.messages.append( - ollama.Message(role=MessageRole.USER.value, content=user_input.text) - ) - - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - {"messages": message_history.messages}, - ) - # Get response # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: - response = await client.chat( + response_generator = await client.chat( model=model, # Make a copy of the messages because we mutate the list later messages=list(message_history.messages), tools=tools, - stream=False, + stream=True, # keep_alive requires specifying unit. In this case, seconds keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, ) except (ollama.RequestError, ollama.ResponseError) as err: _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to the Ollama server: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err - response_message = response["message"] - message_history.messages.append( - ollama.Message( - role=response_message["role"], - content=response_message.get("content"), - tool_calls=response_message.get("tool_calls"), - ) + message_history.messages.extend( + [ + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(response_generator) + ) + ] ) - tool_calls = response_message.get("tool_calls") - if not tool_calls or not llm_api: + if not chat_log.unresponded_tool_results: break - for tool_call in tool_calls: - tool_input = llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - _LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args - ) - - try: - tool_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - - _LOGGER.debug("Tool response: %s", tool_response) - message_history.messages.append( - ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(tool_response), - ) - ) - # Create intent response - intent_response.async_set_speech(response_message["content"]) + intent_response = intent.IntentResponse(language=user_input.language) + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise TypeError( + f"Unexpected last message type: {type(chat_log.content[-1])}" + ) + intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=chat_log.conversation_id ) - def _prune_old_histories(self) -> None: - """Remove old message histories.""" - now = time.monotonic() - self._history = { - conversation_id: message_history - for conversation_id, message_history in self._history.items() - if (now - message_history.timestamp) <= MAX_HISTORY_SECONDS - } - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history.""" + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ if max_messages < 1: # Keep all messages return - if message_history.num_user_messages >= max_messages: + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: # Trim history but keep system prompt (first message). # Every other message should be an assistant message, so keep 2x - # message objects. - num_keep = 2 * max_messages + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 drop_index = len(message_history.messages) - num_keep message_history.messages = [ message_history.messages[0] diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json index dbecbf87e4e..c3f7616ca16 100644 --- a/homeassistant/components/ollama/manifest.json +++ b/homeassistant/components/ollama/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/ollama", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["ollama==0.4.5"] + "requirements": ["ollama==0.4.7"] } diff --git a/homeassistant/components/ollama/models.py b/homeassistant/components/ollama/models.py index 3b6fc958587..fd268664919 100644 --- a/homeassistant/components/ollama/models.py +++ b/homeassistant/components/ollama/models.py @@ -19,9 +19,6 @@ class MessageRole(StrEnum): class MessageHistory: """Chat message history.""" - timestamp: float - """Timestamp of last use in seconds.""" - messages: list[ollama.Message] """List of message history, including system prompt and assistant responses.""" diff --git a/homeassistant/components/ombi/__init__.py b/homeassistant/components/ombi/__init__.py index d63f72592f8..c3a51bacce2 100644 --- a/homeassistant/components/ombi/__init__.py +++ b/homeassistant/components/ombi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py index 72d16f03328..24c8cdf2554 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__) class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): """Class to manage fetching update data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -28,11 +30,11 @@ class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any ) -> None: """Initialize the global Omnilogic data updater.""" self.api = api - self.config_entry = config_entry super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name=name, update_interval=timedelta(seconds=polling_interval), ) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index c87b589e1f6..d941eb3ae4d 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES @@ -22,7 +22,9 @@ from .entity import OmniLogicEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index eb57d03bc34..a9f8bc77d8a 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard from .const import COORDINATOR, DOMAIN, PUMP_TYPES @@ -22,7 +22,9 @@ OMNILOGIC_SWITCH_OFF = 7 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform.""" diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 918d845993a..a4cf814eb2a 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,6 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b33440a9eb7..a590588c009 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -15,16 +16,22 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth +from homeassistant.components.backup import ( + BackupManager, + Folder, + IncorrectPasswordError, + http as backup_http, +) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.hassio import is_hassio +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component -from homeassistant.util.async_ import create_eager_task if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -50,6 +57,9 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) class OnboardingView(HomeAssistantView): @@ -213,32 +223,21 @@ class CoreConfigOnboardingView(_BaseOnboardingView): "shopping_list", ] - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio - - if ( - is_hassio(hass) - and (core_info := hassio.get_core_info(hass)) - and "raspberrypi" in core_info["machine"] - ): - onboard_integrations.append("rpi_power") - - coros: list[Coroutine[Any, Any, Any]] = [ - hass.config_entries.flow.async_init( - domain, context={"source": "onboarding"} + for domain in onboard_integrations: + # Create tasks so onboarding isn't affected + # by errors in these integrations. + hass.async_create_task( + hass.config_entries.flow.async_init( + domain, context={"source": "onboarding"} + ), + f"onboarding_setup_{domain}", ) - for domain in onboard_integrations - ] if "analytics" not in hass.config.components: # If by some chance that analytics has not finished # setting up, wait for it here so its ready for the # next step. - coros.append(async_setup_component(hass, "analytics", {})) - - # Set up integrations after onboarding and ensure - # analytics is ready for the next step. - await asyncio.gather(*(create_eager_task(coro) for coro in coros)) + await async_setup_component(hass, "analytics", {}) return self.json({}) @@ -312,6 +311,124 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) +class BackupOnboardingView(HomeAssistantView): + """Backup onboarding view.""" + + requires_auth = False + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the view.""" + self._data = data + + +def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = await async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"code": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(BackupOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": list(backups.values()), + "state": manager.state, + "last_non_idle_event": manager.last_non_idle_event, + } + ) + + +class RestoreBackupView(BackupOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + except HomeAssistantError as err: + return self.json( + {"code": "restore_failed", "message": str(err)}, + status_code=HTTPStatus.BAD_REQUEST, + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/homeassistant/components/oncue/binary_sensor.py b/homeassistant/components/oncue/binary_sensor.py index 961b082a5c5..8dc9ba1be6f 100644 --- a/homeassistant/components/oncue/binary_sensor.py +++ b/homeassistant/components/oncue/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import OncueEntity from .types import OncueConfigEntry @@ -28,7 +28,7 @@ SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} async def async_setup_entry( hass: HomeAssistant, config_entry: OncueConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/oncue/manifest.json b/homeassistant/components/oncue/manifest.json index b4c425a1645..33d56f23669 100644 --- a/homeassistant/components/oncue/manifest.json +++ b/homeassistant/components/oncue/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/oncue", "iot_class": "cloud_polling", "loggers": ["aiooncue"], - "requirements": ["aiooncue==0.3.7"] + "requirements": ["aiooncue==0.3.9"] } diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py index a0f275ef692..669c34157d4 100644 --- a/homeassistant/components/oncue/sensor.py +++ b/homeassistant/components/oncue/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .entity import OncueEntity @@ -180,7 +180,7 @@ UNIT_MAPPINGS = { async def async_setup_entry( hass: HomeAssistant, config_entry: OncueConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index fb78035c630..ddcd7ab8831 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -8,7 +8,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from .api import OndiloClient from .config_flow import OndiloIcoOAuth2FlowHandler from .const import DOMAIN -from .coordinator import OndiloIcoCoordinator +from .coordinator import OndiloIcoPoolsCoordinator from .oauth_impl import OndiloOauth2Implementation PLATFORMS = [Platform.SENSOR] @@ -28,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - coordinator = OndiloIcoCoordinator(hass, OndiloClient(hass, entry, implementation)) + coordinator = OndiloIcoPoolsCoordinator( + hass, entry, OndiloClient(hass, entry, implementation) + ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index bc092ad0b9a..7545f6d61e0 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -1,77 +1,191 @@ """Define an object to coordinate fetching Ondilo ICO data.""" -from dataclasses import dataclass -from datetime import timedelta +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import datetime, timedelta import logging from typing import Any from ondilo import OndiloError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from . import DOMAIN from .api import OndiloClient _LOGGER = logging.getLogger(__name__) +TIME_TO_NEXT_UPDATE = timedelta(hours=1, minutes=5) +UPDATE_LOCK = asyncio.Lock() + @dataclass -class OndiloIcoData: - """Class for storing the data.""" +class OndiloIcoPoolData: + """Store the pools the data.""" ico: dict[str, Any] pool: dict[str, Any] + measures_coordinator: OndiloIcoMeasuresCoordinator = field(init=False) + + +@dataclass +class OndiloIcoMeasurementData: + """Store the measurement data for one pool.""" + sensors: dict[str, Any] -class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): - """Class to manage fetching Ondilo ICO data from API.""" +class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolData]]): + """Fetch Ondilo ICO pools data from API.""" - def __init__(self, hass: HomeAssistant, api: OndiloClient) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: OndiloClient + ) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, - name=DOMAIN, + config_entry=config_entry, + name=f"{DOMAIN}_pools", update_interval=timedelta(minutes=20), ) self.api = api + self.config_entry = config_entry + self._device_registry = dr.async_get(self.hass) - async def _async_update_data(self) -> dict[str, OndiloIcoData]: - """Fetch data from API endpoint.""" + async def _async_update_data(self) -> dict[str, OndiloIcoPoolData]: + """Fetch pools data from API endpoint and update devices.""" + known_pools: set[str] = set(self.data) if self.data else set() try: - return await self.hass.async_add_executor_job(self._update_data) + async with UPDATE_LOCK: + data = await self.hass.async_add_executor_job(self._update_data) except OndiloError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - def _update_data(self) -> dict[str, OndiloIcoData]: - """Fetch data from API endpoint.""" + current_pools = set(data) + + new_pools = current_pools - known_pools + for pool_id in new_pools: + pool_data = data[pool_id] + pool_data.measures_coordinator = OndiloIcoMeasuresCoordinator( + self.hass, self.config_entry, self.api, pool_id + ) + self._device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, pool_data.ico["serial_number"])}, + manufacturer="Ondilo", + model="ICO", + name=pool_data.pool["name"], + sw_version=pool_data.ico["sw_version"], + ) + + removed_pools = known_pools - current_pools + for pool_id in removed_pools: + pool_data = self.data.pop(pool_id) + await pool_data.measures_coordinator.async_shutdown() + device_entry = self._device_registry.async_get_device( + identifiers={(DOMAIN, pool_data.ico["serial_number"])} + ) + if device_entry: + self._device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + for pool_id in current_pools: + pool_data = data[pool_id] + measures_coordinator = pool_data.measures_coordinator + measures_coordinator.set_next_refresh(pool_data) + if not measures_coordinator.data: + await measures_coordinator.async_refresh() + + return data + + def _update_data(self) -> dict[str, OndiloIcoPoolData]: + """Fetch pools data from API endpoint.""" res = {} pools = self.api.get_pools() _LOGGER.debug("Pools: %s", pools) error: OndiloError | None = None for pool in pools: pool_id = pool["id"] + if (data := self.data) and pool_id in data: + pool_data = res[pool_id] = data[pool_id] + pool_data.pool = pool + # Skip requesting new ICO data for known pools + # to avoid unnecessary API calls. + continue try: ico = self.api.get_ICO_details(pool_id) - if not ico: - _LOGGER.debug( - "The pool id %s does not have any ICO attached", pool_id - ) - continue - sensors = self.api.get_last_pool_measures(pool_id) except OndiloError as err: error = err _LOGGER.debug("Error communicating with API for %s: %s", pool_id, err) continue - res[pool_id] = OndiloIcoData( - ico=ico, - pool=pool, - sensors={sensor["data_type"]: sensor["value"] for sensor in sensors}, - ) + + if not ico: + _LOGGER.debug("The pool id %s does not have any ICO attached", pool_id) + continue + + res[pool_id] = OndiloIcoPoolData(ico=ico, pool=pool) if not res: if error: raise UpdateFailed(f"Error communicating with API: {error}") from error - raise UpdateFailed("No data available") return res + + +class OndiloIcoMeasuresCoordinator(DataUpdateCoordinator[OndiloIcoMeasurementData]): + """Fetch Ondilo ICO measurement data for one pool from API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: OndiloClient, + pool_id: str, + ) -> None: + """Initialize.""" + super().__init__( + hass, + config_entry=config_entry, + logger=_LOGGER, + name=f"{DOMAIN}_measures_{pool_id}", + ) + self.api = api + self._next_refresh: datetime | None = None + self._pool_id = pool_id + + async def _async_update_data(self) -> OndiloIcoMeasurementData: + """Fetch measurement data from API endpoint.""" + async with UPDATE_LOCK: + data = await self.hass.async_add_executor_job(self._update_data) + if next_refresh := self._next_refresh: + now = dt_util.utcnow() + # If we've missed the next refresh, schedule a refresh in one hour. + if next_refresh <= now: + next_refresh = now + timedelta(hours=1) + self.update_interval = next_refresh - now + + return data + + def _update_data(self) -> OndiloIcoMeasurementData: + """Fetch measurement data from API endpoint.""" + try: + sensors = self.api.get_last_pool_measures(self._pool_id) + except OndiloError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + return OndiloIcoMeasurementData( + sensors={sensor["data_type"]: sensor["value"] for sensor in sensors}, + ) + + def set_next_refresh(self, pool_data: OndiloIcoPoolData) -> None: + """Set next refresh of this coordinator.""" + last_update = datetime.fromisoformat(pool_data.pool["updated_at"]) + self._next_refresh = last_update + TIME_TO_NEXT_UPDATE diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 66b07335663..ddc4a94853f 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -15,14 +15,18 @@ from homeassistant.const import ( UnitOfElectricPotential, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OndiloIcoCoordinator, OndiloIcoData +from .coordinator import ( + OndiloIcoMeasuresCoordinator, + OndiloIcoPoolData, + OndiloIcoPoolsCoordinator, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -70,53 +74,72 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ondilo ICO sensors.""" + pools_coordinator: OndiloIcoPoolsCoordinator = hass.data[DOMAIN][entry.entry_id] + known_entities: set[str] = set() - coordinator: OndiloIcoCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(get_new_entities(pools_coordinator, known_entities)) - async_add_entities( - OndiloICO(coordinator, pool_id, description) - for pool_id, pool in coordinator.data.items() - for description in SENSOR_TYPES - if description.key in pool.sensors - ) + @callback + def add_new_entities(): + """Add any new entities after update of the pools coordinator.""" + async_add_entities(get_new_entities(pools_coordinator, known_entities)) + + entry.async_on_unload(pools_coordinator.async_add_listener(add_new_entities)) -class OndiloICO(CoordinatorEntity[OndiloIcoCoordinator], SensorEntity): +@callback +def get_new_entities( + pools_coordinator: OndiloIcoPoolsCoordinator, + known_entities: set[str], +) -> list[OndiloICO]: + """Return new Ondilo ICO sensor entities.""" + entities = [] + for pool_id, pool_data in pools_coordinator.data.items(): + for description in SENSOR_TYPES: + measurement_id = f"{pool_id}-{description.key}" + if ( + measurement_id in known_entities + or (data := pool_data.measures_coordinator.data) is None + or description.key not in data.sensors + ): + continue + known_entities.add(measurement_id) + entities.append( + OndiloICO( + pool_data.measures_coordinator, description, pool_id, pool_data + ) + ) + + return entities + + +class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity): """Representation of a Sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: OndiloIcoCoordinator, - pool_id: str, + coordinator: OndiloIcoMeasuresCoordinator, description: SensorEntityDescription, + pool_id: str, + pool_data: OndiloIcoPoolData, ) -> None: """Initialize sensor entity with data from coordinator.""" super().__init__(coordinator) self.entity_description = description - self._pool_id = pool_id - - data = self.pool_data - self._attr_unique_id = f"{data.ico['serial_number']}-{description.key}" + self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, data.ico["serial_number"])}, - manufacturer="Ondilo", - model="ICO", - name=data.pool["name"], - sw_version=data.ico["sw_version"], + identifiers={(DOMAIN, pool_data.ico["serial_number"])}, ) - @property - def pool_data(self) -> OndiloIcoData: - """Get pool data.""" - return self.coordinator.data[self._pool_id] - @property def native_value(self) -> StateType: """Last value of the sensor.""" - return self.pool_data.sensors[self.entity_description.key] + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py new file mode 100644 index 00000000000..f10b8fe0d91 --- /dev/null +++ b/homeassistant/components/onedrive/__init__.py @@ -0,0 +1,218 @@ +"""The OneDrive integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from html import unescape +from json import dumps, loads +import logging +from typing import cast + +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + NotFoundError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import Item, ItemUpdate + +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .coordinator import ( + OneDriveConfigEntry, + OneDriveRuntimeData, + OneDriveUpdateCoordinator, +) + +PLATFORMS = [Platform.SENSOR] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Set up OneDrive from a config entry.""" + client, get_access_token = await _get_onedrive_client(hass, entry) + + # get approot, will be created automatically if it does not exist + approot = await _handle_item_operation(client.get_approot, "approot") + folder_name = entry.data[CONF_FOLDER_NAME] + + try: + backup_folder = await _handle_item_operation( + lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]), + folder_name, + ) + except NotFoundError: + _LOGGER.debug("Creating backup folder %s", folder_name) + backup_folder = await _handle_item_operation( + lambda: client.create_folder(parent_id=approot.id, name=folder_name), + folder_name, + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id} + ) + + # write instance id to description + if backup_folder.description != (instance_id := await async_get_instance_id(hass)): + await _handle_item_operation( + lambda: client.update_drive_item( + backup_folder.id, ItemUpdate(description=instance_id) + ), + folder_name, + ) + + # update in case folder was renamed manually inside OneDrive + if backup_folder.name != entry.data[CONF_FOLDER_NAME]: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name} + ) + + coordinator = OneDriveUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = OneDriveRuntimeData( + client=client, + token_function=get_access_token, + backup_folder_id=backup_folder.id, + coordinator=coordinator, + ) + + try: + await _migrate_backup_files(client, backup_folder.id) + except OneDriveException as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_files", + ) from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Unload a OneDrive config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None: + """Migrate backup files to metadata version 2.""" + files = await client.list_drive_items(backup_folder_id) + for file in files: + if file.description and '"metadata_version": 1' in ( + metadata_json := unescape(file.description) + ): + metadata = loads(metadata_json) + del metadata["metadata_version"] + metadata_filename = file.name.rsplit(".", 1)[0] + ".metadata.json" + metadata_file = await client.upload_file( + backup_folder_id, + metadata_filename, + dumps(metadata), + ) + metadata_description = { + "metadata_version": 2, + "backup_id": metadata["backup_id"], + "backup_file_id": file.id, + } + await client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + await client.update_drive_item( + path_or_id=file.id, + data=ItemUpdate(description=""), + ) + _LOGGER.debug("Migrated backup file %s", file.name) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1: + _LOGGER.debug( + "Migrating OneDrive config entry from version %s.%s", version, minor_version + ) + client, _ = await _get_onedrive_client(hass, entry) + instance_id = await async_get_instance_id(hass) + try: + approot = await client.get_approot() + folder = await client.get_drive_item( + f"{approot.id}:/backups_{instance_id[:8]}:" + ) + except OneDriveException: + _LOGGER.exception("Migration to version 1.2 failed") + return False + + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_FOLDER_ID: folder.id, + CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", + }, + minor_version=2, + ) + _LOGGER.debug("Migration to version 1.2 successful") + return True + + +async def _get_onedrive_client( + hass: HomeAssistant, entry: OneDriveConfigEntry +) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: + """Get OneDrive client.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return ( + OneDriveClient(get_access_token, async_get_clientsession(hass)), + get_access_token, + ) + + +async def _handle_item_operation( + func: Callable[[], Awaitable[Item]], folder: str +) -> Item: + try: + return await func() + except NotFoundError: + raise + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err diff --git a/homeassistant/components/onedrive/application_credentials.py b/homeassistant/components/onedrive/application_credentials.py new file mode 100644 index 00000000000..b38aa9313d0 --- /dev/null +++ b/homeassistant/components/onedrive/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for the OneDrive integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py new file mode 100644 index 00000000000..9c7371bee4b --- /dev/null +++ b/homeassistant/components/onedrive/backup.py @@ -0,0 +1,258 @@ +"""Support for OneDrive backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from dataclasses import dataclass +from functools import wraps +from html import unescape +from json import dumps, loads +import logging +from time import time +from typing import Any, Concatenate + +from aiohttp import ClientTimeout +from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import ItemUpdate +from onedrive_personal_sdk.models.upload import FileInfo + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .coordinator import OneDriveConfigEntry + +_LOGGER = logging.getLogger(__name__) +UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB +TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours +METADATA_VERSION = 2 +CACHE_TTL = 300 + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[OneDriveConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [OneDriveBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper( + self: OneDriveBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except AuthenticationError as err: + self._entry.async_start_reauth(self._hass) + raise BackupAgentError("Authentication error") from err + except OneDriveException as err: + _LOGGER.error( + "Error during backup in %s:, message %s", + func.__name__, + err, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError("Backup operation failed") from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +@dataclass(kw_only=True) +class OneDriveBackup: + """Define a OneDrive backup.""" + + backup: AgentBackup + backup_file_id: str + metadata_file_id: str + + +class OneDriveBackupAgent(BackupAgent): + """OneDrive backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: + """Initialize the OneDrive backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._client = entry.runtime_data.client + self._token_function = entry.runtime_data.token_function + self._folder_id = entry.runtime_data.backup_folder_id + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + self._backup_cache: dict[str, OneDriveBackup] = {} + self._cache_expiration = time() + + @handle_backup_errors + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + backups = await self._list_cached_backups() + if backup_id not in backups: + raise BackupNotFound("Backup not found") + + stream = await self._client.download_drive_item( + backups[backup_id].backup_file_id, timeout=TIMEOUT + ) + return stream.iter_chunked(1024) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + filename = suggested_filename(backup) + file = FileInfo( + filename, + backup.size, + self._folder_id, + await open_stream(), + ) + try: + backup_file = await LargeFileUploadClient.upload( + self._token_function, file, session=async_get_clientsession(self._hass) + ) + except HashMismatchError as err: + raise BackupAgentError( + "Hash validation failed, backup file might be corrupt" + ) from err + + # store metadata in metadata file + description = dumps(backup.as_dict()) + _LOGGER.debug("Creating metadata: %s", description) + metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json" + metadata_file = await self._client.upload_file( + self._folder_id, + metadata_filename, + description, + ) + + # add metadata to the metadata file + metadata_description = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_file_id": backup_file.id, + } + await self._client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + self._cache_expiration = time() + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + backups = await self._list_cached_backups() + if backup_id not in backups: + return + + backup = backups[backup_id] + + delete_permanently = self._entry.options.get(CONF_DELETE_PERMANENTLY, False) + + await self._client.delete_drive_item(backup.backup_file_id, delete_permanently) + await self._client.delete_drive_item( + backup.metadata_file_id, delete_permanently + ) + self._cache_expiration = time() + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return [ + backup.backup for backup in (await self._list_cached_backups()).values() + ] + + @handle_backup_errors + async def async_get_backup( + self, backup_id: str, **kwargs: Any + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self._list_cached_backups() + return backups[backup_id].backup if backup_id in backups else None + + async def _list_cached_backups(self) -> dict[str, OneDriveBackup]: + """List backups with a cache.""" + if time() <= self._cache_expiration: + return self._backup_cache + + items = await self._client.list_drive_items(self._folder_id) + + async def download_backup_metadata(item_id: str) -> AgentBackup: + metadata_stream = await self._client.download_drive_item(item_id) + metadata_json = loads(await metadata_stream.read()) + return AgentBackup.from_dict(metadata_json) + + backups: dict[str, OneDriveBackup] = {} + for item in items: + if item.description and f'"metadata_version": {METADATA_VERSION}' in ( + metadata_description_json := unescape(item.description) + ): + backup = await download_backup_metadata(item.id) + metadata_description = loads(metadata_description_json) + backups[backup.backup_id] = OneDriveBackup( + backup=backup, + backup_file_id=metadata_description["backup_file_id"], + metadata_file_id=item.id, + ) + + self._cache_expiration = time() + CACHE_TTL + self._backup_cache = backups + return backups diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py new file mode 100644 index 00000000000..3374c0369ee --- /dev/null +++ b/homeassistant/components/onedrive/config_flow.py @@ -0,0 +1,260 @@ +"""Config flow for OneDrive.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from onedrive_personal_sdk.clients.client import OneDriveClient +from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .const import ( + CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) +from .coordinator import OneDriveConfigEntry + +FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str}) + + +class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle OneDrive OAuth2 authentication.""" + + DOMAIN = DOMAIN + MINOR_VERSION = 2 + + client: OneDriveClient + approot: AppRoot + + def __init__(self) -> None: + """Initialize the OneDrive config flow.""" + super().__init__() + self.step_data: dict[str, Any] = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH_SCOPES)} + + @property + def apps_folder(self) -> str: + """Return the name of the Apps folder (translated).""" + return ( + path.split("/")[-1] + if (path := self.approot.parent_reference.path) + else "Apps" + ) + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle the initial step.""" + + async def get_access_token() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + + self.client = OneDriveClient( + get_access_token, async_get_clientsession(self.hass) + ) + + try: + self.approot = await self.client.get_approot() + except OneDriveException: + self.logger.exception("Failed to connect to OneDrive") + return self.async_abort(reason="connection_error") + except Exception: + self.logger.exception("Unknown error") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(self.approot.parent_reference.drive_id) + + if self.source != SOURCE_USER: + self._abort_if_unique_id_mismatch( + reason="wrong_drive", + ) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + entry=reauth_entry, + data=data, + ) + + if self.source != SOURCE_RECONFIGURE: + self._abort_if_unique_id_configured() + + self.step_data = data + + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_reconfigure_folder() + + return await self.async_step_folder_name() + + async def async_step_folder_name( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask for the folder name.""" + errors: dict[str, str] = {} + instance_id = await async_get_instance_id(self.hass) + if user_input is not None: + try: + folder = await self.client.create_folder( + self.approot.id, user_input[CONF_FOLDER_NAME] + ) + except OneDriveException: + self.logger.debug("Failed to create folder", exc_info=True) + errors["base"] = "folder_creation_error" + else: + if folder.description and folder.description != instance_id: + errors[CONF_FOLDER_NAME] = "folder_already_in_use" + if not errors: + title = ( + f"{self.approot.created_by.user.display_name}'s OneDrive" + if self.approot.created_by.user + and self.approot.created_by.user.display_name + else "OneDrive" + ) + return self.async_create_entry( + title=title, + data={ + **self.step_data, + CONF_FOLDER_ID: folder.id, + CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME], + }, + ) + + default_folder_name = ( + f"backups_{instance_id[:8]}" + if user_input is None + else user_input[CONF_FOLDER_NAME] + ) + + return self.async_show_form( + step_id="folder_name", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name} + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, + ) + + async def async_step_reconfigure_folder( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the folder name.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + if ( + new_folder_name := user_input[CONF_FOLDER_NAME] + ) != reconfigure_entry.data[CONF_FOLDER_NAME]: + try: + await self.client.update_drive_item( + reconfigure_entry.data[CONF_FOLDER_ID], + ItemUpdate(name=new_folder_name), + ) + except OneDriveException: + self.logger.debug("Failed to update folder", exc_info=True) + errors["base"] = "folder_rename_error" + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name}, + ) + + return self.async_show_form( + step_id="reconfigure_folder", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, + {CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]}, + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_user() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: OneDriveConfigEntry, + ) -> OneDriveOptionsFlowHandler: + """Create the options flow.""" + return OneDriveOptionsFlowHandler() + + +class OneDriveOptionsFlowHandler(OptionsFlow): + """Handles options flow for the component.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options for OneDrive.""" + if user_input: + return self.async_create_entry(title="", data=user_input) + + options_schema = vol.Schema( + { + vol.Optional( + CONF_DELETE_PERMANENTLY, + default=self.config_entry.options.get( + CONF_DELETE_PERMANENTLY, False + ), + ): bool, + } + ) + + return self.async_show_form( + step_id="init", + data_schema=options_schema, + ) diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py new file mode 100644 index 00000000000..fd21d84369c --- /dev/null +++ b/homeassistant/components/onedrive/const.py @@ -0,0 +1,28 @@ +"""Constants for the OneDrive integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "onedrive" +CONF_FOLDER_NAME: Final = "folder_name" +CONF_FOLDER_ID: Final = "folder_id" + +CONF_DELETE_PERMANENTLY: Final = "delete_permanently" + +# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support +OAUTH2_AUTHORIZE: Final = ( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" +) +OAUTH2_TOKEN: Final = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +OAUTH_SCOPES: Final = [ + "Files.ReadWrite.AppFolder", + "offline_access", + "openid", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py new file mode 100644 index 00000000000..7b2dbaab87a --- /dev/null +++ b/homeassistant/components/onedrive/coordinator.py @@ -0,0 +1,95 @@ +"""Coordinator for OneDrive.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging + +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.models.items import Drive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + client: OneDriveClient + token_function: Callable[[], Awaitable[str]] + backup_folder_id: str + coordinator: OneDriveUpdateCoordinator + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + + +class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): + """Class to handle fetching data from the Graph API centrally.""" + + config_entry: OneDriveConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._client = client + + async def _async_update_data(self) -> Drive: + """Fetch data from API endpoint.""" + + try: + drive = await self._client.get_drive() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OneDriveException as err: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from err + + # create an issue if the drive is almost full + if drive.quota and (state := drive.quota.state) in ( + DriveState.CRITICAL, + DriveState.EXCEEDED, + ): + key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full" + ir.async_create_issue( + self.hass, + DOMAIN, + key, + is_fixable=False, + severity=( + ir.IssueSeverity.ERROR + if state is DriveState.EXCEEDED + else ir.IssueSeverity.WARNING + ), + translation_key=key, + translation_placeholders={ + "total": str(drive.quota.total), + "used": str(drive.quota.used), + }, + ) + return drive diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py new file mode 100644 index 00000000000..0e1ed94e155 --- /dev/null +++ b/homeassistant/components/onedrive/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json new file mode 100644 index 00000000000..b693f69934e --- /dev/null +++ b/homeassistant/components/onedrive/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "total_size": { + "default": "mdi:database" + }, + "used_size": { + "default": "mdi:database" + }, + "remaining_size": { + "default": "mdi:database" + }, + "drive_state": { + "default": "mdi:harddisk", + "state": { + "normal": "mdi:harddisk", + "nearing": "mdi:alert-circle-outline", + "critical": "mdi:alert", + "exceeded": "mdi:alert-octagon" + } + } + } + } +} diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json new file mode 100644 index 00000000000..31a1f2ccb06 --- /dev/null +++ b/homeassistant/components/onedrive/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "onedrive", + "name": "OneDrive", + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/onedrive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["onedrive_personal_sdk"], + "quality_scale": "platinum", + "requirements": ["onedrive-personal-sdk==0.0.12"] +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml new file mode 100644 index 00000000000..023410d89b2 --- /dev/null +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py new file mode 100644 index 00000000000..fa7c0b125fe --- /dev/null +++ b/homeassistant/components/onedrive/sensor.py @@ -0,0 +1,122 @@ +"""Sensors for OneDrive.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.models.items import DriveQuota + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry, OneDriveUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class OneDriveSensorEntityDescription(SensorEntityDescription): + """Describes OneDrive sensor entity.""" + + value_fn: Callable[[DriveQuota], StateType] + + +DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( + OneDriveSensorEntityDescription( + key="total_size", + value_fn=lambda quota: quota.total, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + OneDriveSensorEntityDescription( + key="used_size", + value_fn=lambda quota: quota.used, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveSensorEntityDescription( + key="remaining_size", + value_fn=lambda quota: quota.remaining, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveSensorEntityDescription( + key="drive_state", + value_fn=lambda quota: quota.state.value, + options=[state.value for state in DriveState], + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OneDriveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OneDrive sensors based on a config entry.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + OneDriveDriveStateSensor(coordinator, description) + for description in DRIVE_STATE_ENTITIES + ) + + +class OneDriveDriveStateSensor( + CoordinatorEntity[OneDriveUpdateCoordinator], SensorEntity +): + """Define a OneDrive sensor.""" + + entity_description: OneDriveSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OneDriveUpdateCoordinator, + description: OneDriveSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_unique_id = f"{coordinator.data.id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data.name or coordinator.config_entry.title, + identifiers={(DOMAIN, coordinator.data.id)}, + manufacturer="Microsoft", + model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", + configuration_url=f"https://onedrive.live.com/?id=root&cid={coordinator.data.id}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + assert self.coordinator.data.quota + return self.entity_description.value_fn(self.coordinator.data.quota) + + @property + def available(self) -> bool: + """Availability of the sensor.""" + return super().available and self.coordinator.data.quota is not None diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json new file mode 100644 index 00000000000..37e19eb68ca --- /dev/null +++ b/homeassistant/components/onedrive/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The OneDrive integration needs to re-authenticate your account" + }, + "folder_name": { + "title": "Pick a folder name", + "description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`", + "data": { + "folder_name": "Folder name" + }, + "data_description": { + "folder_name": "Name of the folder" + } + }, + "reconfigure_folder": { + "title": "Change the folder name", + "description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.", + "data": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]" + }, + "data_description": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "connection_error": "Failed to connect to OneDrive.", + "wrong_drive": "New account does not contain previously configured OneDrive.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "error": { + "folder_rename_error": "Failed to rename folder", + "folder_creation_error": "Failed to create folder", + "folder_already_in_use": "Folder already used for backups from another Home Assistant instance" + } + }, + "options": { + "step": { + "init": { + "description": "By default, files are put into the Recycle Bin when deleted, where they remain available for another 30 days. If you enable this option, files will be deleted immediately when they are cleaned up by the backup system.", + "data": { + "delete_permanently": "Delete files permanently" + }, + "data_description": { + "delete_permanently": "Delete files without moving them to the Recycle Bin" + } + } + } + }, + "issues": { + "drive_full": { + "title": "OneDrive data cap exceeded", + "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB." + }, + "drive_almost_full": { + "title": "OneDrive near data cap", + "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB." + } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed" + }, + "failed_to_get_folder": { + "message": "Failed to get {folder} folder" + }, + "failed_to_migrate_files": { + "message": "Failed to migrate metadata to separate files" + }, + "update_failed": { + "message": "Failed to update drive state" + } + }, + "entity": { + "sensor": { + "total_size": { + "name": "Total available storage" + }, + "used_size": { + "name": "Used storage" + }, + "remaining_size": { + "name": "Remaining storage" + }, + "drive_state": { + "name": "Drive state", + "state": { + "normal": "Normal", + "nearing": "Nearing limit", + "critical": "Critical", + "exceeded": "Exceeded" + } + } + } + } +} diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index b144e12795e..c77d87d91b9 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -4,30 +4,40 @@ import logging from pyownet import protocol +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, PLATFORMS -from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub +from .const import DOMAIN +from .onewirehub import OneWireConfigEntry, OneWireHub _LOGGER = logging.getLogger(__name__) +_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] + async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" - onewire_hub = OneWireHub(hass) + onewire_hub = OneWireHub(hass, entry) try: - await onewire_hub.initialize(entry) + await onewire_hub.initialize() except ( - CannotConnect, # Failed to connect to the server + protocol.ConnError, # Failed to connect to the server protocol.OwnetError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady from exc entry.runtime_data = onewire_hub - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + onewire_hub.schedule_scan_for_new_devices() entry.async_on_unload(entry.add_update_listener(options_update_listener)) @@ -48,7 +58,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) async def options_update_listener( diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 5d3c71b5eae..2bb393e48a8 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -13,13 +13,21 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) -PARALLEL_UPDATES = 1 +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=30) @@ -93,22 +101,31 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireBinarySensorEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireBinarySensor] = [] - for device in onewire_hub.devices: + entities: list[OneWireBinarySensorEntity] = [] + for device in devices: family = device.family device_id = device.id device_type = device.type @@ -123,7 +140,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireBinarySensor( + OneWireBinarySensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -135,7 +152,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: return entities -class OneWireBinarySensor(OneWireEntity, BinarySensorEntity): +class OneWireBinarySensorEntity(OneWireEntity, BinarySensorEntity): """Implementation of a 1-Wire binary sensor.""" entity_description: OneWireBinarySensorEntityDescription diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 31c0d35ee4b..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -5,6 +5,7 @@ from __future__ import annotations from copy import deepcopy from typing import Any +from pyownet import protocol import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow @@ -12,6 +13,8 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_HOST, @@ -24,7 +27,7 @@ from .const import ( OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, ) -from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub +from .onewirehub import OneWireConfigEntry DATA_SCHEMA = vol.Schema( { @@ -38,11 +41,11 @@ async def validate_input( hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] ) -> None: """Validate the user input allows us to connect.""" - - hub = OneWireHub(hass) try: - await hub.connect(data[CONF_HOST], data[CONF_PORT]) - except CannotConnect: + await hass.async_add_executor_job( + protocol.proxy, data[CONF_HOST], data[CONF_PORT] + ) + except protocol.ConnError: errors["base"] = "cannot_connect" @@ -50,6 +53,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): """Handle 1-Wire config flow.""" VERSION = 1 + _discovery_data: dict[str, Any] async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -99,6 +103,54 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle hassio discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.config["addon"], + CONF_HOST: discovery_info.config[CONF_HOST], + CONF_PORT: discovery_info.config[CONF_PORT], + } + return await self.async_step_discovery_confirm() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + await self._async_handle_discovery_without_unique_id() + + self._discovery_data = { + "title": discovery_info.name, + CONF_HOST: discovery_info.hostname, + CONF_PORT: discovery_info.port, + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + data = { + CONF_HOST: self._discovery_data[CONF_HOST], + CONF_PORT: self._discovery_data[CONF_PORT], + } + await validate_input(self.hass, data, errors) + if not errors: + return self.async_create_entry( + title=self._discovery_data["title"], data=data + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, + errors=errors, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index a4f3ebe9a78..57cdd8c483c 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -2,8 +2,6 @@ from __future__ import annotations -from homeassistant.const import Platform - DEFAULT_HOST = "localhost" DEFAULT_PORT = 4304 @@ -12,6 +10,7 @@ DOMAIN = "onewire" DEVICE_KEYS_0_3 = range(4) DEVICE_KEYS_0_7 = range(8) DEVICE_KEYS_A_B = ("A", "B") +DEVICE_KEYS_A_D = ("A", "B", "C", "D") DEVICE_SUPPORT = { "05": (), @@ -19,6 +18,7 @@ DEVICE_SUPPORT = { "12": (), "1D": (), "1F": (), + "20": (), "22": (), "26": (), "28": (), @@ -54,9 +54,3 @@ MANUFACTURER_EDS = "Embedded Data Systems" READ_MODE_BOOL = "bool" READ_MODE_FLOAT = "float" READ_MODE_INT = "int" - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.SENSOR, - Platform.SWITCH, -] diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index bbf36deaaa0..2ea21aca488 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -54,6 +54,7 @@ class OneWireEntity(Entity): """Return the state attributes of the entity.""" return { "device_file": self._device_file, + # raw_value attribute is deprecated and can be removed in 2025.8 "raw_value": self._value_raw, } @@ -84,4 +85,4 @@ class OneWireEntity(Entity): elif self.entity_description.read_mode == READ_MODE_BOOL: self._state = int(self._value_raw) == 1 else: - self._state = round(self._value_raw, 1) + self._state = self._value_raw diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json index 4f3cb5d04ab..844c4c1afb9 100644 --- a/homeassistant/components/onewire/manifest.json +++ b/homeassistant/components/onewire/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyownet"], - "requirements": ["pyownet==0.10.0.post1"] + "requirements": ["pyownet==0.10.0.post1"], + "zeroconf": ["_owserver._tcp.local."] } diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index 3bf4de006f5..d65d7a90950 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,26 +2,20 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging import os -from typing import TYPE_CHECKING from pyownet import protocol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_VIA_DEVICE, - CONF_HOST, - CONF_PORT, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ATTR_VIA_DEVICE, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.signal_type import SignalType from .const import ( DEVICE_SUPPORT, @@ -42,10 +36,15 @@ DEVICE_MANUFACTURER = { "EF": MANUFACTURER_HOBBYBOARDS, } +_DEVICE_SCAN_INTERVAL = timedelta(minutes=5) _LOGGER = logging.getLogger(__name__) type OneWireConfigEntry = ConfigEntry[OneWireHub] +SIGNAL_NEW_DEVICE_CONNECTED = SignalType["OneWireHub", list[OWDeviceDescription]]( + f"{DOMAIN}_new_device_connected" +) + def _is_known_device(device_family: str, device_type: str | None) -> bool: """Check if device family/type is known to the library.""" @@ -57,116 +56,122 @@ def _is_known_device(device_family: str, device_type: str | None) -> bool: class OneWireHub: """Hub to communicate with server.""" - def __init__(self, hass: HomeAssistant) -> None: + owproxy: protocol._Proxy + devices: list[OWDeviceDescription] + _version: str + + def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" - self.hass = hass - self.owproxy: protocol._Proxy | None = None - self.devices: list[OWDeviceDescription] | None = None + self._hass = hass + self._config_entry = config_entry - async def connect(self, host: str, port: int) -> None: - """Connect to the server.""" - try: - self.owproxy = await self.hass.async_add_executor_job( - protocol.proxy, host, port - ) - except protocol.ConnError as exc: - raise CannotConnect from exc + def _initialize(self) -> None: + """Connect to the server, and discover connected devices. - async def initialize(self, config_entry: OneWireConfigEntry) -> None: - """Initialize a config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] + Needs to be run in executor. + """ + host = self._config_entry.data[CONF_HOST] + port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) - await self.connect(host, port) - await self.discover_devices() - if TYPE_CHECKING: - assert self.devices - # Register discovered devices on Hub - device_registry = dr.async_get(self.hass) - for device in self.devices: - device_info: DeviceInfo = device.device_info + self.owproxy = protocol.proxy(host, port) + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() + self.devices = _discover_devices(self.owproxy) + + async def initialize(self) -> None: + """Initialize a config entry.""" + await self._hass.async_add_executor_job(self._initialize) + self._populate_device_registry(self.devices) + + @callback + def _populate_device_registry(self, devices: list[OWDeviceDescription]) -> None: + """Populate the device registry.""" + device_registry = dr.async_get(self._hass) + for device in devices: + device.device_info["sw_version"] = self._version device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers=device_info[ATTR_IDENTIFIERS], - manufacturer=device_info[ATTR_MANUFACTURER], - model=device_info[ATTR_MODEL], - name=device_info[ATTR_NAME], - via_device=device_info.get(ATTR_VIA_DEVICE), + config_entry_id=self._config_entry.entry_id, + **device.device_info, ) - async def discover_devices(self) -> None: - """Discover all devices.""" - if self.devices is None: - self.devices = await self.hass.async_add_executor_job( - self._discover_devices + def schedule_scan_for_new_devices(self) -> None: + """Schedule a regular scan of the bus for new devices.""" + self._config_entry.async_on_unload( + async_track_time_interval( + self._hass, self._scan_for_new_devices, _DEVICE_SCAN_INTERVAL + ) + ) + + async def _scan_for_new_devices(self, _: datetime) -> None: + """Scan the bus for new devices.""" + devices = await self._hass.async_add_executor_job( + _discover_devices, self.owproxy + ) + existing_device_ids = [device.id for device in self.devices] + new_devices = [ + device for device in devices if device.id not in existing_device_ids + ] + if new_devices: + self.devices.extend(new_devices) + self._populate_device_registry(new_devices) + async_dispatcher_send( + self._hass, SIGNAL_NEW_DEVICE_CONNECTED, self, new_devices ) - def _discover_devices( - self, path: str = "/", parent_id: str | None = None - ) -> list[OWDeviceDescription]: - """Discover all server devices.""" - devices: list[OWDeviceDescription] = [] - assert self.owproxy - for device_path in self.owproxy.dir(path): - device_id = os.path.split(os.path.split(device_path)[0])[1] - device_family = self.owproxy.read(f"{device_path}family").decode() - _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) - device_type = self._get_device_type(device_path) - if not _is_known_device(device_family, device_type): - _LOGGER.warning( - "Ignoring unknown device family/type (%s/%s) found for device %s", - device_family, - device_type, - device_id, + +def _discover_devices( + owproxy: protocol._Proxy, path: str = "/", parent_id: str | None = None +) -> list[OWDeviceDescription]: + """Discover all server devices.""" + devices: list[OWDeviceDescription] = [] + for device_path in owproxy.dir(path): + device_id = os.path.split(os.path.split(device_path)[0])[1] + device_family = owproxy.read(f"{device_path}family").decode() + _LOGGER.debug("read `%sfamily`: %s", device_path, device_family) + device_type = _get_device_type(owproxy, device_path) + if not _is_known_device(device_family, device_type): + _LOGGER.warning( + "Ignoring unknown device family/type (%s/%s) found for device %s", + device_family, + device_type, + device_id, + ) + continue + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer=DEVICE_MANUFACTURER.get(device_family, MANUFACTURER_MAXIM), + model=device_type, + model_id=device_type, + name=device_id, + serial_number=device_id[3:], + ) + if parent_id: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) + device = OWDeviceDescription( + device_info=device_info, + id=device_id, + family=device_family, + path=device_path, + type=device_type, + ) + devices.append(device) + if device_branches := DEVICE_COUPLERS.get(device_family): + for branch in device_branches: + devices += _discover_devices( + owproxy, f"{device_path}{branch}", device_id ) - continue - device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, device_id)}, - ATTR_MANUFACTURER: DEVICE_MANUFACTURER.get( - device_family, MANUFACTURER_MAXIM - ), - ATTR_MODEL: device_type, - ATTR_NAME: device_id, - } - if parent_id: - device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_id) - device = OWDeviceDescription( - device_info=device_info, - id=device_id, - family=device_family, - path=device_path, - type=device_type, - ) - devices.append(device) - if device_branches := DEVICE_COUPLERS.get(device_family): - for branch in device_branches: - devices += self._discover_devices( - f"{device_path}{branch}", device_id - ) - return devices - - def _get_device_type(self, device_path: str) -> str | None: - """Get device model.""" - if TYPE_CHECKING: - assert self.owproxy - try: - device_type = self.owproxy.read(f"{device_path}type").decode() - except protocol.ProtocolError as exc: - _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) - return None - _LOGGER.debug("read `%stype`: %s", device_path, device_type) - if device_type == "EDS": - device_type = self.owproxy.read(f"{device_path}device_type").decode() - _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) - if TYPE_CHECKING: - assert isinstance(device_type, str) - return device_type + return devices -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidPath(HomeAssistantError): - """Error to indicate the path is invalid.""" +def _get_device_type(owproxy: protocol._Proxy, device_path: str) -> str | None: + """Get device model.""" + try: + device_type: str = owproxy.read(f"{device_path}type").decode() + except protocol.ProtocolError as exc: + _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) + return None + _LOGGER.debug("read `%stype`: %s", device_path, device_type) + if device_type == "EDS": + device_type = owproxy.read(f"{device_path}device_type").decode() + _LOGGER.debug("read `%sdevice_type`: %s", device_path, device_type) + return device_type diff --git a/homeassistant/components/onewire/quality_scale.yaml b/homeassistant/components/onewire/quality_scale.yaml new file mode 100644 index 00000000000..d46ed69f0d6 --- /dev/null +++ b/homeassistant/components/onewire/quality_scale.yaml @@ -0,0 +1,126 @@ +rules: + ## Bronze + config-flow: + status: todo + comment: missing data_description on options flow + test-before-configure: done + unique-config-entry: + status: done + comment: unique ID is not available, but duplicates are prevented based on host/port + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: entities do not subscribe to events + dependency-transparency: + status: todo + comment: The package is not built and published inside a CI pipeline + action-setup: + status: exempt + comment: No service actions currently available + common-modules: + status: done + comment: base entity available, but no coordinator + docs-high-level-description: + status: todo + comment: Under review + docs-installation-instructions: + status: todo + comment: Under review + docs-removal-instructions: + status: todo + comment: Under review + docs-actions: + status: todo + comment: Under review + brands: done + + ## Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No service actions currently available + reauthentication-flow: + status: exempt + comment: Local polling without authentication + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: todo + comment: Under review + docs-configuration-parameters: + status: todo + comment: Under review + + ## Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: + status: done + comment: hassio and mDNS/zeroconf discovery implemented + stale-devices: + status: done + comment: > + Manual removal, as it is not possible to distinguish + between a flaky device and a device that has been removed + diagnostics: + status: todo + comment: config-entry diagnostics level available, might be nice to have device-level diagnostics + exception-translations: + status: todo + comment: Under review + icon-translations: + status: exempt + comment: It doesn't make sense to override defaults + reconfiguration-flow: done + dynamic-devices: + status: done + comment: The bus is scanned for new devices at regular interval + discovery-update-info: + status: todo + comment: Under review + repair-issues: + status: exempt + comment: No repairs available + docs-use-cases: + status: todo + comment: Under review + docs-supported-devices: + status: todo + comment: Under review + docs-supported-functions: + status: todo + comment: Under review + docs-data-update: + status: todo + comment: Under review + docs-known-limitations: + status: todo + comment: Under review + docs-troubleshooting: + status: todo + comment: Under review + docs-examples: + status: todo + comment: Under review + + ## Platinum + async-dependency: + status: todo + comment: The dependency is not async + inject-websession: + status: exempt + comment: No websession + strict-typing: + status: todo + comment: The dependency is not typed diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py new file mode 100644 index 00000000000..7f4111243aa --- /dev/null +++ b/homeassistant/components/onewire/select.py @@ -0,0 +1,110 @@ +"""Support for 1-Wire environment select entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import os + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import READ_MODE_INT +from .entity import OneWireEntity, OneWireEntityDescription +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) + +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=30) + + +@dataclass(frozen=True) +class OneWireSelectEntityDescription(OneWireEntityDescription, SelectEntityDescription): + """Class describing OneWire select entities.""" + + +ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = { + "28": ( + OneWireSelectEntityDescription( + key="tempres", + entity_category=EntityCategory.CONFIG, + read_mode=READ_MODE_INT, + options=["9", "10", "11", "12"], + translation_key="tempres", + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OneWireConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up 1-Wire platform.""" + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) + ) + + +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSelectEntity]: + """Get a list of entities.""" + entities: list[OneWireSelectEntity] = [] + + for device in devices: + family = device.family + device_id = device.id + device_info = device.device_info + + if family not in ENTITY_DESCRIPTIONS: + continue + for description in ENTITY_DESCRIPTIONS[family]: + device_file = os.path.join(os.path.split(device.path)[0], description.key) + entities.append( + OneWireSelectEntity( + description=description, + device_id=device_id, + device_file=device_file, + device_info=device_info, + owproxy=onewire_hub.owproxy, + ) + ) + + return entities + + +class OneWireSelectEntity(OneWireEntity, SelectEntity): + """Implementation of a 1-Wire switch.""" + + entity_description: OneWireSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return str(self._state) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._write_value(option.encode("ascii")) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index e345550c265..5e1c7d35bd6 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -26,12 +26,14 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, + DEVICE_KEYS_A_D, OPTION_ENTRY_DEVICE_OPTIONS, OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, @@ -39,9 +41,16 @@ from .const import ( READ_MODE_INT, ) from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) -PARALLEL_UPDATES = 1 +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +109,33 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + "20": tuple( + [ + OneWireSensorEntityDescription( + key=f"latestvolt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="latest_voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + + [ + OneWireSensorEntityDescription( + key=f"volt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), "26": ( SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, @@ -352,25 +388,38 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data, config_entry.options + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + # note: we have to go through the executor as SENSOR platform + # makes extra calls to the hub during device listing + entities = await hass.async_add_executor_job( + get_entities, hub, devices, config_entry.options + ) + async_add_entities(entities, True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) def get_entities( - onewire_hub: OneWireHub, options: MappingProxyType[str, Any] -) -> list[OneWireSensor]: + onewire_hub: OneWireHub, + devices: list[OWDeviceDescription], + options: MappingProxyType[str, Any], +) -> list[OneWireSensorEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] - - entities: list[OneWireSensor] = [] - assert onewire_hub.owproxy - for device in onewire_hub.devices: + entities: list[OneWireSensorEntity] = [] + for device in devices: family = device.family device_type = device.type device_id = device.id @@ -424,7 +473,7 @@ def get_entities( ) continue entities.append( - OneWireSensor( + OneWireSensorEntity( description=description, device_id=device_id, device_file=device_file, @@ -435,7 +484,7 @@ def get_entities( return entities -class OneWireSensor(OneWireEntity, SensorEntity): +class OneWireSensorEntity(OneWireEntity, SensorEntity): """Implementation of a 1-Wire sensor.""" entity_description: OneWireSensorEntityDescription diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index cd8615dc5aa..46f41503d97 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", @@ -41,6 +44,17 @@ "name": "Hub short on branch {id}" } }, + "select": { + "tempres": { + "name": "Temperature resolution", + "state": { + "9": "9 bits (0.5°C, fastest, up to 93.75ms)", + "10": "10 bits (0.25°C, up to 187.5ms)", + "11": "11 bits (0.125°C, up to 375ms)", + "12": "12 bits (0.0625°C, slowest, up to 750ms)" + } + } + }, "sensor": { "counter_id": { "name": "Counter {id}" @@ -60,12 +74,18 @@ "humidity_raw": { "name": "Raw humidity" }, + "latest_voltage_id": { + "name": "Latest voltage {id}" + }, "moisture_id": { "name": "Moisture {id}" }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, + "voltage_id": { + "name": "Voltage {id}" + }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 57f4f41924e..d2cc3b80185 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -10,13 +10,21 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription -from .onewirehub import OneWireConfigEntry, OneWireHub +from .onewirehub import ( + SIGNAL_NEW_DEVICE_CONNECTED, + OneWireConfigEntry, + OneWireHub, + OWDeviceDescription, +) -PARALLEL_UPDATES = 1 +# the library uses non-persistent connections +# and concurrent access to the bus is managed by the server +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=30) @@ -153,23 +161,32 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - entities = await hass.async_add_executor_job( - get_entities, config_entry.runtime_data + + async def _add_entities( + hub: OneWireHub, devices: list[OWDeviceDescription] + ) -> None: + """Add 1-Wire entities for all devices.""" + if not devices: + return + async_add_entities(get_entities(hub, devices), True) + + hub = config_entry.runtime_data + await _add_entities(hub, hub.devices) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_NEW_DEVICE_CONNECTED, _add_entities) ) - async_add_entities(entities, True) -def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: +def get_entities( + onewire_hub: OneWireHub, devices: list[OWDeviceDescription] +) -> list[OneWireSwitchEntity]: """Get a list of entities.""" - if not onewire_hub.devices: - return [] + entities: list[OneWireSwitchEntity] = [] - entities: list[OneWireSwitch] = [] - - for device in onewire_hub.devices: + for device in devices: family = device.family device_type = device.type device_id = device.id @@ -187,7 +204,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: for description in get_sensor_types(device_sub_type)[family]: device_file = os.path.join(os.path.split(device.path)[0], description.key) entities.append( - OneWireSwitch( + OneWireSwitchEntity( description=description, device_id=device_id, device_file=device_file, @@ -199,7 +216,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: return entities -class OneWireSwitch(OneWireEntity, SwitchEntity): +class OneWireSwitchEntity(OneWireEntity, SwitchEntity): """Implementation of a 1-Wire switch.""" entity_description: OneWireSwitchEntityDescription diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index fd5c0ba634a..2ebe86da561 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -1,6 +1,7 @@ """The onkyo component.""" from dataclasses import dataclass +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -9,10 +10,18 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, OPTION_INPUT_SOURCES, InputSource +from .const import ( + DOMAIN, + OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, + InputSource, + ListeningMode, +) from .receiver import Receiver, async_interview from .services import DATA_MP_ENTITIES, async_register_services +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -24,6 +33,7 @@ class OnkyoData: receiver: Receiver sources: dict[InputSource, str] + sound_modes: dict[ListeningMode, str] type OnkyoConfigEntry = ConfigEntry[OnkyoData] @@ -50,7 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} - entry.runtime_data = OnkyoData(receiver, sources) + sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) + sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} + + entry.runtime_data = OnkyoData(receiver, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index a484b3aaa04..5d941be959a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -1,12 +1,12 @@ """Config flow for Onkyo.""" +from collections.abc import Mapping import logging from typing import Any import voluptuous as vol from yarl import URL -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -16,6 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -26,18 +27,21 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONF_RECEIVER_MAX_VOLUME, CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, OPTION_VOLUME_RESOLUTION_DEFAULT, VOLUME_RESOLUTION_ALLOWED, InputSource, + ListeningMode, ) from .receiver import ReceiverInfo, async_discover, async_interview @@ -45,16 +49,32 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_ALL_MEANINGS = [ - input_source.value_meaning for input_source in InputSource -] +INPUT_SOURCES_DEFAULT: dict[str, str] = {} +LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_ALL_MEANINGS = { + input_source.value_meaning: input_source for input_source in InputSource +} +LISTENING_MODES_ALL_MEANINGS = { + listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode +} STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -STEP_CONFIGURE_SCHEMA = vol.Schema( +STEP_RECONFIGURE_SCHEMA = vol.Schema( { vol.Required(OPTION_VOLUME_RESOLUTION): vol.In(VOLUME_RESOLUTION_ALLOWED), + } +) +STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( + { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -168,7 +188,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle flow initialized by SSDP discovery.""" _LOGGER.debug("Config flow start ssdp: %s", discovery_info) @@ -216,55 +236,64 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the configuration of a single receiver.""" errors = {} - entry = None - entry_options = None + reconfigure_entry = None + schema = STEP_CONFIGURE_SCHEMA if self.source == SOURCE_RECONFIGURE: - entry = self._get_reconfigure_entry() - entry_options = entry.options + schema = STEP_RECONFIGURE_SCHEMA + reconfigure_entry = self._get_reconfigure_entry() if user_input is not None: - source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] - if not source_meanings: + volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] + + if reconfigure_entry is not None: + entry_options = reconfigure_entry.options + result = self.async_update_reload_and_abort( + reconfigure_entry, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + **entry_options, + OPTION_VOLUME_RESOLUTION: volume_resolution, + }, + ) + + _LOGGER.debug("Reconfigured receiver, result: %s", result) + return result + + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: - sources_store: dict[str, str] = {} - for source_meaning in source_meanings: - source = InputSource.from_meaning(source_meaning) - source_name = source_meaning - if entry_options is not None: - source_name = entry_options[OPTION_INPUT_SOURCES].get( - source.value, source_name - ) - sources_store[source.value] = source_name + listening_modes: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_modes: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" - volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] + if not errors: + input_sources_store: dict[str, str] = {} + for input_source_meaning in input_source_meanings: + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] + input_sources_store[input_source.value] = input_source_meaning - if entry_options is None: - result = self.async_create_entry( - title=self._receiver_info.model_name, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, - OPTION_INPUT_SOURCES: sources_store, - }, - ) - else: - assert entry is not None - result = self.async_update_reload_and_abort( - entry, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, - }, - ) + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning in listening_modes: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_modes_store[listening_mode.value] = listening_mode_meaning + + result = self.async_create_entry( + title=self._receiver_info.model_name, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, + }, + ) _LOGGER.debug("Configured receiver, result: %s", result) return result @@ -273,25 +302,21 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): suggested_values = user_input if suggested_values is None: - if entry_options is None: + if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: [], + OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, } else: + entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], - OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning - for input_source in entry_options[OPTION_INPUT_SOURCES] - ], } return self.async_show_form( step_id="configure_receiver", - data_schema=self.add_suggested_values_to_schema( - STEP_CONFIGURE_SCHEMA, suggested_values - ), + data_schema=self.add_suggested_values_to_schema(schema, suggested_values), errors=errors, description_placeholders={ "name": f"{self._receiver_info.model_name} ({self._receiver_info.host})" @@ -355,62 +380,165 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: max_volume, OPTION_INPUT_SOURCES: sources_store, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, }, ) @staticmethod @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Return the options flow.""" - return OnkyoOptionsFlowHandler(config_entry) + return OnkyoOptionsFlowHandler() + + +OPTIONS_STEP_INIT_SCHEMA = vol.Schema( + { + vol.Required(OPTION_MAX_VOLUME): NumberSelector( + NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) + ), + vol.Required(OPTION_INPUT_SOURCES): SelectSelector( + SelectSelectorConfig( + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) class OnkyoOptionsFlowHandler(OptionsFlow): """Handle an options flow for Onkyo.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] - self._input_sources = {InputSource(k): v for k, v in sources_store.items()} + _data: dict[str, Any] + _input_sources: dict[InputSource, str] + _listening_modes: dict[ListeningMode, str] async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + errors = {} + + entry_options: Mapping[str, Any] = self.config_entry.options + entry_options = { + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + **entry_options, + } + if user_input is not None: - sources_store: dict[str, str] = {} - for source_meaning, source_name in user_input.items(): - if source_meaning in INPUT_SOURCES_ALL_MEANINGS: - source = InputSource.from_meaning(source_meaning) - sources_store[source.value] = source_name + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: + errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - return self.async_create_entry( - data={ - OPTION_VOLUME_RESOLUTION: self.config_entry.options[ - OPTION_VOLUME_RESOLUTION - ], + listening_mode_meanings: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_mode_meanings: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: + self._input_sources = {} + for input_source_meaning in input_source_meanings: + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + self._listening_modes = {} + for listening_mode_meaning in listening_mode_meanings: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_mode_name = entry_options[OPTION_LISTENING_MODES].get( + listening_mode.value, listening_mode_meaning + ) + self._listening_modes[listening_mode] = listening_mode_name + + self._data = { + OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, } - ) - schema_dict: dict[Any, Selector] = {} + return await self.async_step_names() - max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME] - schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = ( - NumberSelector( - NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) - ) - ) - - for source, source_name in self._input_sources.items(): - schema_dict[vol.Required(source.value_meaning, default=source_name)] = ( - TextSelector() - ) + suggested_values = user_input + if suggested_values is None: + suggested_values = { + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: [ + InputSource(input_source).value_meaning + for input_source in entry_options[OPTION_INPUT_SOURCES] + ], + OPTION_LISTENING_MODES: [ + ListeningMode(listening_mode).value_meaning + for listening_mode in entry_options[OPTION_LISTENING_MODES] + ], + } return self.async_show_form( step_id="init", - data_schema=vol.Schema(schema_dict), + data_schema=self.add_suggested_values_to_schema( + OPTIONS_STEP_INIT_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def async_step_names( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure names.""" + if user_input is not None: + input_sources_store: dict[str, str] = {} + for input_source_meaning, input_source_name in user_input[ + OPTION_INPUT_SOURCES + ].items(): + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] + input_sources_store[input_source.value] = input_source_name + + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning, listening_mode_name in user_input[ + OPTION_LISTENING_MODES + ].items(): + listening_mode = LISTENING_MODES_ALL_MEANINGS[listening_mode_meaning] + listening_modes_store[listening_mode.value] = listening_mode_name + + return self.async_create_entry( + data={ + **self._data, + OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, + } + ) + + input_sources_schema_dict: dict[Any, Selector] = {} + for input_source, input_source_name in self._input_sources.items(): + input_sources_schema_dict[ + vol.Required(input_source.value_meaning, default=input_source_name) + ] = TextSelector() + + listening_modes_schema_dict: dict[Any, Selector] = {} + for listening_mode, listening_mode_name in self._listening_modes.items(): + listening_modes_schema_dict[ + vol.Required(listening_mode.value_meaning, default=listening_mode_name) + ] = TextSelector() + + return self.async_show_form( + step_id="names", + data_schema=vol.Schema( + { + vol.Required(OPTION_INPUT_SOURCES): section( + vol.Schema(input_sources_schema_dict) + ), + vol.Required(OPTION_LISTENING_MODES): section( + vol.Schema(listening_modes_schema_dict) + ), + } + ), ) diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index bd4fe98ae7d..fcb1a8a0a9e 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -2,7 +2,7 @@ from enum import Enum import typing -from typing import ClassVar, Literal, Self +from typing import Literal, Self import pyeiscp @@ -24,7 +24,27 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 + +class EnumWithMeaning(Enum): + """Enum with meaning.""" + + value_meaning: str + + def __new__(cls, value: str) -> Self: + """Create enum.""" + obj = object.__new__(cls) + obj._value_ = value + obj.value_meaning = cls._get_meanings()[value] + + return obj + + @staticmethod + def _get_meanings() -> dict[str, str]: + raise NotImplementedError + + OPTION_INPUT_SOURCES = "input_sources" +OPTION_LISTENING_MODES = "listening_modes" _INPUT_SOURCE_MEANINGS = { "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", @@ -71,7 +91,7 @@ _INPUT_SOURCE_MEANINGS = { } -class InputSource(Enum): +class InputSource(EnumWithMeaning): """Receiver input source.""" DVR = "00" @@ -116,24 +136,100 @@ class InputSource(Enum): HDMI_7 = "57" MAIN_SOURCE = "80" - __meaning_mapping: ClassVar[dict[str, Self]] = {} # type: ignore[misc] + @staticmethod + def _get_meanings() -> dict[str, str]: + return _INPUT_SOURCE_MEANINGS - value_meaning: str - def __new__(cls, value: str) -> Self: - """Create InputSource enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = _INPUT_SOURCE_MEANINGS[value] +_LISTENING_MODE_MEANINGS = { + "00": "STEREO", + "01": "DIRECT", + "02": "SURROUND", + "03": "FILM ··· GAME RPG ··· ADVANCED GAME", + "04": "THX", + "05": "ACTION ··· GAME ACTION", + "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", + "07": "MONO MOVIE", + "08": "ORCHESTRA ··· CLASSICAL", + "09": "UNPLUGGED", + "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", + "0B": "TV LOGIC ··· DRAMA", + "0C": "ALL CH STEREO ··· EXTENDED STEREO", + "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", + "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", + "0F": "MONO", + "11": "PURE AUDIO ··· PURE DIRECT", + "12": "MULTIPLEX", + "13": "FULL MONO ··· MONO MUSIC", + "14": "DOLBY VIRTUAL/SURROUND ENHANCER", + "15": "DTS SURROUND SENSATION", + "16": "AUDYSSEY DSX", + "17": "DTS VIRTUAL:X", + "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", + "23": "STAGE (JAPAN GENRE CONTROL)", + "25": "ACTION (JAPAN GENRE CONTROL)", + "26": "MUSIC (JAPAN GENRE CONTROL)", + "2E": "SPORTS (JAPAN GENRE CONTROL)", + "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", + "41": "DOLBY EX/DTS ES", + "42": "THX CINEMA", + "43": "THX SURROUND EX", + "44": "THX MUSIC", + "45": "THX GAMES", + "50": "THX U(2)/S(2)/I/S CINEMA", + "51": "THX U(2)/S(2)/I/S MUSIC", + "52": "THX U(2)/S(2)/I/S GAMES", + "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", + "81": "PLII/PLIIx MUSIC", + "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", + "83": "NEO:6/NEO:X MUSIC", + "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", + "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", + "86": "PLII/PLIIx GAME", + "87": "NEURAL SURR", + "88": "NEURAL THX/NEURAL SURROUND", + "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", + "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", + "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", + "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", + "8D": "NEURAL THX CINEMA", + "8E": "NEURAL THX MUSIC", + "8F": "NEURAL THX GAMES", + "90": "PLIIz HEIGHT", + "91": "NEO:6 CINEMA DTS SURROUND SENSATION", + "92": "NEO:6 MUSIC DTS SURROUND SENSATION", + "93": "NEURAL DIGITAL MUSIC", + "94": "PLIIz HEIGHT + THX CINEMA", + "95": "PLIIz HEIGHT + THX MUSIC", + "96": "PLIIz HEIGHT + THX GAMES", + "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", + "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", + "99": "PLIIz HEIGHT + THX U2/S2 GAMES", + "9A": "NEO:X GAME", + "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", + "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", + "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", + "A3": "NEO:6 CINEMA + AUDYSSEY DSX", + "A4": "NEO:6 MUSIC + AUDYSSEY DSX", + "A5": "NEURAL SURROUND + AUDYSSEY DSX", + "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", + "A7": "DOLBY EX + AUDYSSEY DSX", + "FF": "AUTO SURROUND", +} - cls.__meaning_mapping[obj.value_meaning] = obj - return obj +class ListeningMode(EnumWithMeaning): + """Receiver listening mode.""" - @classmethod - def from_meaning(cls, meaning: str) -> Self: - """Get InputSource enum from its meaning.""" - return cls.__meaning_mapping[meaning] + _ignore_ = "ListeningMode _k _v _meaning" + + ListeningMode = vars() + for _k in _LISTENING_MODE_MEANINGS: + ListeningMode["I" + _k] = _k + + @staticmethod + def _get_meanings() -> dict[str, str]: + return _LISTENING_MODE_MEANINGS ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 97a82fc8a1a..8f9587bc426 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from enum import Enum from functools import cache import logging from typing import Any, Literal @@ -22,7 +23,10 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, ca from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -36,6 +40,7 @@ from .const import ( PYEISCP_COMMANDS, ZONES, InputSource, + ListeningMode, VolumeResolution, ) from .receiver import Receiver, async_discover @@ -60,6 +65,8 @@ CONF_SOURCES_DEFAULT = { "fm": "Radio", } +ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" + PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST): cv.string, @@ -76,23 +83,23 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( } ) -SUPPORT_ONKYO_WO_VOLUME = ( + +SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA ) -SUPPORT_ONKYO = ( - SUPPORT_ONKYO_WO_VOLUME - | MediaPlayerEntityFeature.VOLUME_SET +SUPPORTED_FEATURES_VOLUME = ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP ) -DEFAULT_PLAYABLE_SOURCES = ( - InputSource.from_meaning("FM"), - InputSource.from_meaning("AM"), - InputSource.from_meaning("TUNER"), +PLAYABLE_SOURCES = ( + InputSource.FM, + InputSource.AM, + InputSource.DAB, ) ATTR_PRESET = "preset" @@ -115,7 +122,6 @@ AUDIO_INFORMATION_MAPPING = [ "auto_phase_control_phase", "upmix_mode", ] - VIDEO_INFORMATION_MAPPING = [ "video_input_port", "input_resolution", @@ -128,7 +134,6 @@ VIDEO_INFORMATION_MAPPING = [ "picture_mode", "input_hdr", ] -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" type LibValue = str | tuple[str, ...] @@ -136,7 +141,19 @@ type LibValue = str | tuple[str, ...] def _get_single_lib_value(value: LibValue) -> str: if isinstance(value, str): return value - return value[0] + return value[-1] + + +def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: + result: dict[T, LibValue] = {} + for k, v in cmds["values"].items(): + try: + key = cls(k) + except ValueError: + continue + result[key] = v["name"] + + return result @cache @@ -151,15 +168,7 @@ def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: case "zone4": cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - result: dict[InputSource, LibValue] = {} - for k, v in cmds["values"].items(): - try: - source = InputSource(k) - except ValueError: - continue - result[source] = v["name"] - - return result + return _get_lib_mapping(cmds, InputSource) @cache @@ -167,6 +176,24 @@ def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: return {value: key for key, value in _input_source_lib_mappings(zone).items()} +@cache +def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["LMD"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] + case _: + return {} + + return _get_lib_mapping(cmds, ListeningMode) + + +@cache +def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: + return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -286,7 +313,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: OnkyoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MediaPlayer for config entry.""" data = entry.runtime_data @@ -300,6 +327,7 @@ async def async_setup_entry( volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] max_volume: float = entry.options[OPTION_MAX_VOLUME] sources = data.sources + sound_modes = data.sound_modes def connect_callback(receiver: Receiver) -> None: if not receiver.first_connect: @@ -328,6 +356,7 @@ async def async_setup_entry( volume_resolution=volume_resolution, max_volume=max_volume, sources=sources, + sound_modes=sound_modes, ) entities[zone] = zone_entity async_add_entities([zone_entity]) @@ -342,6 +371,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): _attr_should_poll = False _supports_volume: bool = False + _supports_sound_mode: bool = False _supports_audio_info: bool = False _supports_video_info: bool = False _query_timer: asyncio.TimerHandle | None = None @@ -354,6 +384,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): volume_resolution: VolumeResolution, max_volume: float, sources: dict[InputSource, str], + sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" self._receiver = receiver @@ -367,6 +398,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume + self._options_sources = sources self._source_lib_mapping = _input_source_lib_mappings(zone) self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) self._source_mapping = { @@ -378,7 +410,28 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._options_sound_modes = sound_modes + self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) + self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + self._sound_mode_mapping = { + key: value + for key, value in sound_modes.items() + if key in self._sound_mode_lib_mapping + } + self._rev_sound_mode_mapping = { + value: key for key, value in self._sound_mode_mapping.items() + } + self._attr_source_list = list(self._rev_source_mapping) + self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) + + self._attr_supported_features = SUPPORTED_FEATURES_BASE + if zone == "main": + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE + self._supports_sound_mode = True + self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: @@ -391,13 +444,6 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._query_timer.cancel() self._query_timer = None - @property - def supported_features(self) -> MediaPlayerEntityFeature: - """Return media player features that are supported.""" - if self._supports_volume: - return SUPPORT_ONKYO - return SUPPORT_ONKYO_WO_VOLUME - @callback def _update_receiver(self, propname: str, value: Any) -> None: """Update a property in the receiver.""" @@ -463,6 +509,24 @@ class OnkyoMediaPlayer(MediaPlayerEntity): "input-selector" if self._zone == "main" else "selector", source_lib_single ) + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select listening sound mode.""" + if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={ + "invalid_sound_mode": sound_mode, + "entity_id": self.entity_id, + }, + ) + + sound_mode_lib = self._sound_mode_lib_mapping[ + self._rev_sound_mode_mapping[sound_mode] + ] + sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) + self._update_receiver("listening-mode", sound_mode_lib_single) + async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" self._update_receiver("hdmi-output-selector", hdmi_output) @@ -473,7 +537,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """Play radio station by preset number.""" if self.source is not None: source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: + if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @callback @@ -514,7 +578,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes.pop(ATTR_PRESET, None) self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) elif command in ["volume", "master-volume"] and value != "N/A": - self._supports_volume = True + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) volume_level: float = value / ( self._volume_resolution * self._max_volume / 100 @@ -532,6 +598,14 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes[ATTR_PRESET] = value elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] + elif command == "listening-mode" and value != "N/A": + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + self._parse_sound_mode(value) + self._query_av_info_delayed() elif command == "audio-information": self._supports_audio_info = True self._parse_audio_information(value) @@ -551,13 +625,46 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return source_meaning = source.value_meaning - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) + + if source not in self._options_sources: + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Input source "%s" is invalid for entity: %s', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning + @callback + def _parse_sound_mode(self, mode_lib: LibValue) -> None: + sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + return + + sound_mode_meaning = sound_mode.value_meaning + + if sound_mode not in self._options_sound_modes: + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + + self._attr_sound_mode = sound_mode_meaning + @callback def _parse_audio_information( self, audio_information: tuple[str] | Literal["N/A"] diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index cdcf88e72d7..4b9fbe7c019 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -16,7 +16,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: | @@ -45,8 +45,8 @@ rules: # Gold devices: todo diagnostics: todo - discovery: todo - discovery-update-info: todo + discovery: done + discovery-update-info: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index 849171c7161..d8131dd1149 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -27,17 +27,20 @@ "description": "Configure {name}", "data": { "volume_resolution": "Volume resolution", - "input_sources": "Input sources" + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data::listening_modes%]" }, "data_description": { "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", - "input_sources": "List of input sources supported by the receiver." + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data_description::listening_modes%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "empty_input_source_list": "Input source list cannot be empty", + "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", + "empty_listening_mode_list": "[%key:component::onkyo::options::error::empty_listening_mode_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -52,12 +55,32 @@ "step": { "init": { "data": { - "max_volume": "Maximum volume limit (%)" + "max_volume": "Maximum volume limit (%)", + "input_sources": "Input sources", + "listening_modes": "Listening modes" }, "data_description": { - "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value." + "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", + "input_sources": "List of input sources supported by the receiver.", + "listening_modes": "List of listening modes supported by the receiver." + } + }, + "names": { + "sections": { + "input_sources": { + "name": "Input source names", + "description": "Mappings of receiver's input sources to their names." + }, + "listening_modes": { + "name": "Listening mode names", + "description": "Mappings of receiver's listening modes to their names." + } } } + }, + "error": { + "empty_input_source_list": "Input source list cannot be empty", + "empty_listening_mode_list": "Listening mode list cannot be empty" } }, "issues": { @@ -71,6 +94,9 @@ } }, "exceptions": { + "invalid_sound_mode": { + "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." + }, "invalid_source": { "message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}." } diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 92c5ab45129..d29f732ef67 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.enum import try_parse_enum @@ -22,7 +22,7 @@ from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index 644a7c942f7..8e92cb07a8c 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .device import ONVIFDevice @@ -14,7 +14,7 @@ from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF button based on a config entry.""" device = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 8c0fd027b95..da99e170ff6 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -22,7 +22,7 @@ from homeassistant.const import HTTP_BASIC_AUTHENTICATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ABSOLUTE_MOVE, @@ -57,7 +57,7 @@ from .models import Profile async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ONVIF camera video stream.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 66e566af0bf..f645444f9c6 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -11,11 +11,11 @@ from urllib.parse import urlparse from onvif.util import is_auth_error, stringify_onvif_error import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery +from wsdiscovery.qname import QName from wsdiscovery.scope import Scope from wsdiscovery.service import Service from zeep.exceptions import Fault -from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -39,6 +39,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_DEVICE_ID, @@ -58,16 +59,22 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device" def wsdiscovery() -> list[Service]: """Get ONVIF Profile S devices from network.""" - discovery = WSDiscovery(ttl=4) + discovery = WSDiscovery(ttl=4, relates_to=True) try: discovery.start() return discovery.searchServices( - scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] + types=[ + QName( + "http://www.onvif.org/ver10/network/wsdl", + "NetworkVideoTransmitter", + "dp0", + ) + ], + scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")], + timeout=10, ) finally: discovery.stop() - # Stop the threads started by WSDiscovery since otherwise there is a leak. - discovery._stopThreads() # noqa: SLF001 async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: @@ -170,7 +177,7 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" hass = self.hass diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f51b1b74686..3f37ba42397 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -25,7 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ABSOLUTE_MOVE, @@ -235,7 +235,7 @@ class ONVIFDevice: LOGGER.debug("%s: Retrieving current device date/time", self.name) try: device_time = await device_mgmt.GetSystemDateAndTime() - except RequestError as err: + except (RequestError, Fault) as err: LOGGER.warning( "Couldn't get device '%s' date/time. Error: %s", self.name, err ) @@ -263,16 +263,22 @@ class ONVIFDevice: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) return - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) + try: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + except ValueError as err: + LOGGER.warning( + "%s: Could not parse date/time from camera: %s", self.name, err + ) + return cam_date_utc = cam_date.astimezone(dt_util.UTC) diff --git a/homeassistant/components/onvif/entity.py b/homeassistant/components/onvif/entity.py index c9900106256..783df743e86 100644 --- a/homeassistant/components/onvif/entity.py +++ b/homeassistant/components/onvif/entity.py @@ -17,7 +17,7 @@ class ONVIFBaseEntity(Entity): self.device: ONVIFDevice = device @property - def available(self): + def available(self) -> bool: """Return True if device is available.""" return self.device.available diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 4b5335f1eb6..b7b34f7be9f 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -252,9 +252,9 @@ class PullPointManager: async def async_start(self) -> bool: """Start pullpoint subscription.""" - assert ( - self.state == PullPointManagerState.STOPPED - ), "PullPoint manager already started" + assert self.state == PullPointManagerState.STOPPED, ( + "PullPoint manager already started" + ) LOGGER.debug("%s: Starting PullPoint manager", self._name) if not await self._async_start_pullpoint(): self.state = PullPointManagerState.FAILED @@ -501,9 +501,9 @@ class WebHookManager: async def async_start(self) -> bool: """Start polling events.""" LOGGER.debug("%s: Starting webhook manager", self._name) - assert ( - self.state == WebHookManagerState.STOPPED - ), "Webhook manager already started" + assert self.state == WebHookManagerState.STOPPED, ( + "Webhook manager already started" + ) assert self._webhook_url is None, "Webhook already registered" self._async_register_webhook() if not await self._async_start_webhook(): diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 02ef16b6787..78df5130aed 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,12 +1,12 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm"], + "codeowners": ["@hunterjm", "@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.2.5", "WSDiscovery==2.1.2"] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index d7bbaa4fb3f..6eb1d001796 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +import dataclasses import datetime from typing import Any @@ -370,22 +371,59 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: return None -@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") +_TAPO_EVENT_TEMPLATES: dict[str, Event] = { + "IsVehicle": Event( + uid="", + name="Vehicle Detection", + platform="binary_sensor", + device_class="motion", + ), + "IsPeople": Event( + uid="", name="Person Detection", platform="binary_sensor", device_class="motion" + ), + "IsPet": Event( + uid="", name="Pet Detection", platform="binary_sensor", device_class="motion" + ), + "IsLineCross": Event( + uid="", + name="Line Detector Crossed", + platform="binary_sensor", + device_class="motion", + ), + "IsTamper": Event( + uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper" + ), + "IsIntrusion": Event( + uid="", + name="Intrusion Detection", + platform="binary_sensor", + device_class="safety", + ), +} + + +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper") +@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent") @PARSERS.register("tns1:RuleEngine/PeopleDetector/People") +@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent") async def async_parse_tplink_detector(uid: str, msg) -> Event | None: """Handle parsing tplink smart event messages. - Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent + Topic: tns1:RuleEngine/CellMotionDetector/Intrusion + Topic: tns1:RuleEngine/CellMotionDetector/LineCross + Topic: tns1:RuleEngine/CellMotionDetector/People + Topic: tns1:RuleEngine/CellMotionDetector/Tamper + Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent Topic: tns1:RuleEngine/PeopleDetector/People + Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent """ - video_source = "" - video_analytics = "" - rule = "" - topic = "" - vehicle = False - person = False - enabled = False try: + video_source = "" + video_analytics = "" + rule = "" topic, payload = extract_message(msg) for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": @@ -396,34 +434,19 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: rule = source.Value for item in payload.Data.SimpleItem: - if item.Name == "IsVehicle": - vehicle = True - enabled = item.Value == "true" - if item.Name == "IsPeople": - person = True - enabled = item.Value == "true" + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue + + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) + except (AttributeError, KeyError): return None - if vehicle: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - if person: - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Person Detection", - "binary_sensor", - "motion", - None, - enabled, - ) - return None diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 46db26361bc..a0162a05f76 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.enum import try_parse_enum @@ -21,7 +21,7 @@ from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index ff62e469af0..d8e1020c6a3 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .device import ONVIFDevice @@ -66,7 +66,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF switch platform.""" device = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 51ee91de083..9782051ab22 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -20,7 +20,7 @@ from homeassistant.components.weather import ( from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -31,7 +31,7 @@ from .coordinator import OpenMeteoConfigEntry async def async_setup_entry( hass: HomeAssistant, entry: OpenMeteoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Open-Meteo weather entity based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2a1764e6b5e..c631884ea0b 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, + SelectSelectorMode, TemplateSelector, ) from homeassistant.helpers.typing import VolDictType @@ -32,14 +33,17 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + UNSUPPORTED_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -124,26 +128,32 @@ class OpenAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: + errors[CONF_CHAT_MODEL] = "model_not_supported" + else: + return self.async_create_entry(title="", data=user_input) + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), + errors=errors, ) @@ -210,6 +220,17 @@ def openai_config_option_schema( description={"suggested_value": options.get(CONF_TEMPERATURE)}, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + vol.Optional( + CONF_REASONING_EFFORT, + description={"suggested_value": options.get(CONF_REASONING_EFFORT)}, + default=RECOMMENDED_REASONING_EFFORT, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key="reasoning_effort", + mode=SelectSelectorMode.DROPDOWN, + ) + ), } ) return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index e8ee003fcca..793e021e332 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -15,3 +15,17 @@ CONF_TOP_P = "top_p" RECOMMENDED_TOP_P = 1.0 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 +CONF_REASONING_EFFORT = "reasoning_effort" +RECOMMENDED_REASONING_EFFORT = "low" + +UNSUPPORTED_MODELS = [ + "o1-mini", + "o1-mini-2024-09-12", + "o1-preview", + "o1-preview-2024-09-12", + "gpt-4o-realtime-preview", + "gpt-4o-realtime-preview-2024-12-17", + "gpt-4o-realtime-preview-2024-10-01", + "gpt-4o-mini-realtime-preview", + "gpt-4o-mini-realtime-preview-2024-12-17", +] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index b3f31ae9b47..cc09ec77c0e 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,48 +1,45 @@ """Conversation support for OpenAI.""" -from collections.abc import Callable -from dataclasses import dataclass, field +from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal +from typing import Any, Literal, cast import openai +from openai._streaming import AsyncStream from openai._types import NOT_GIVEN from openai.types.chat import ( ChatCompletionAssistantMessageParam, - ChatCompletionMessage, + ChatCompletionChunk, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, ChatCompletionToolParam, - ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.shared_params import FunctionDefinition -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, ) @@ -54,7 +51,7 @@ MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( hass: HomeAssistant, config_entry: OpenAIConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = OpenAIConversationEntity(config_entry) @@ -74,12 +71,111 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) -@dataclass -class ChatHistory: - """Class holding the chat history.""" +def _convert_content_to_param( + content: conversation.Content, +) -> ChatCompletionMessageParam: + """Convert any native chat message for this agent to the native format.""" + if content.role == "tool_result": + assert type(content) is conversation.ToolResultContent + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + role = content.role + if role == "system": + role = "developer" + return cast( + ChatCompletionMessageParam, + {"role": content.role, "content": content.content}, # type: ignore[union-attr] + ) - extra_system_prompt: str | None = None - messages: list[ChatCompletionMessageParam] = field(default_factory=list) + # Handle the Assistant content including tool calls. + assert type(content) is conversation.AssistantContent + return ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + type="function", + ) + for tool_call in content.tool_calls + ], + ) + + +async def _transform_stream( + result: AsyncStream[ChatCompletionChunk], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + current_tool_call: dict | None = None + + async for chunk in result: + LOGGER.debug("Received chunk: %s", chunk) + choice = chunk.choices[0] + + if choice.finish_reason: + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=json.loads(current_tool_call["tool_args"]), + ) + ] + } + + break + + delta = chunk.choices[0].delta + + # We can yield delta messages not continuing or starting tool calls + if current_tool_call is None and not delta.tool_calls: + yield { # type: ignore[misc] + key: value + for key in ("role", "content") + if (value := getattr(delta, key)) is not None + } + continue + + # When doing tool calls, we should always have a tool call + # object or we have gotten stopped above with a finish_reason set. + if ( + not delta.tool_calls + or not (delta_tool_call := delta.tool_calls[0]) + or not delta_tool_call.function + ): + raise ValueError("Expected delta with tool call") + + if current_tool_call and delta_tool_call.index == current_tool_call["index"]: + current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" + continue + + # We got tool call with new index, so we need to yield the previous + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=json.loads(current_tool_call["tool_args"]), + ) + ] + } + + current_tool_call = { + "index": delta_tool_call.index, + "id": delta_tool_call.id, + "tool_name": delta_tool_call.function.name, + "tool_args": delta_tool_call.function.arguments or "", + } class OpenAIConversationEntity( @@ -93,7 +189,6 @@ class OpenAIConversationEntity( def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, ChatHistory] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -132,219 +227,90 @@ class OpenAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - options = self.entry.options - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[ChatCompletionToolParam] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - - if options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Error preparing LLM API", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] - - history: ChatHistory | None = None - - if user_input.conversation_id is None: - conversation_id = ulid.ulid_now() - - elif user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - history = self.history.get(conversation_id) - - else: - # Conversation IDs are ULIDs. We generate a new one if not provided. - # If an old OLID is passed in, we will generate a new one to indicate - # a new conversation was started. If the user picks their own, they - # want to track a conversation and we respect it. - try: - ulid.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid.ulid_now() - except ValueError: - conversation_id = user_input.conversation_id - - if history is None: - history = ChatHistory(user_input.extra_system_prompt) - - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, ): - user_name = user.name + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Call the API.""" + options = self.entry.options try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) + await chat_log.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[ChatCompletionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem with my template", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - extra_system_prompt = ( - # Take new system prompt if one was given - user_input.extra_system_prompt or history.extra_system_prompt - ) - - if extra_system_prompt: - prompt_parts.append(extra_system_prompt) - - prompt = "\n".join(prompt_parts) - - # Create a copy of the variable because we attach it to the trace - history = ChatHistory( - extra_system_prompt, - [ - ChatCompletionSystemMessageParam(role="system", content=prompt), - *history.messages[1:], - ChatCompletionUserMessageParam(role="user", content=user_input.text), - ], - ) - - LOGGER.debug("Prompt: %s", history.messages) - LOGGER.debug("Tools: %s", tools) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - {"messages": history.messages, "tools": llm_api.tools if llm_api else None}, - ) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + messages = [_convert_content_to_param(content) for content in chat_log.content] client = self.entry.runtime_data # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - try: - result = await client.chat.completions.create( - model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=history.messages, - tools=tools or NOT_GIVEN, - max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - user=conversation_id, + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_completion_tokens": options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + "stream": True, + } + + if model.startswith("o"): + model_args["reasoning_effort"] = options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) + + try: + result = await client.chat.completions.create(**model_args) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem talking to OpenAI", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + raise HomeAssistantError("Error talking to OpenAI") from err - LOGGER.debug("Response %s", result) - response = result.choices[0].message + messages.extend( + [ + _convert_content_to_param(content) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(result) + ) + ] + ) - def message_convert( - message: ChatCompletionMessage, - ) -> ChatCompletionMessageParam: - """Convert from class to TypedDict.""" - tool_calls: list[ChatCompletionMessageToolCallParam] = [] - if message.tool_calls: - tool_calls = [ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=tool_call.function.arguments, - name=tool_call.function.name, - ), - type=tool_call.type, - ) - for tool_call in message.tool_calls - ] - param = ChatCompletionAssistantMessageParam( - role=message.role, - content=message.content, - ) - if tool_calls: - param["tool_calls"] = tool_calls - return param - - history.messages.append(message_convert(response)) - tool_calls = response.tool_calls - - if not tool_calls or not llm_api: + if not chat_log.unresponded_tool_results: break - for tool_call in tool_calls: - tool_input = llm.ToolInput( - tool_name=tool_call.function.name, - tool_args=json.loads(tool_call.function.arguments), - ) - LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args - ) - - try: - tool_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", tool_response) - history.messages.append( - ChatCompletionToolMessageParam( - role="tool", - tool_call_id=tool_call.id, - content=json.dumps(tool_response), - ) - ) - - self.history[conversation_id] = history - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response.content or "") + assert type(chat_log.content[-1]) is conversation.AssistantContent + intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=chat_log.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index fcbdc996ce5..a7aa7884dc4 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.35.7"] + "requirements": ["openai==1.61.0"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 2477155e3cb..b8768f8abbe 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -23,12 +23,26 @@ "temperature": "Temperature", "top_p": "Top P", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "recommended": "Recommended model settings", + "reasoning_effort": "Reasoning effort" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)" } } + }, + "error": { + "model_not_supported": "This model is not supported, please select a different model" + } + }, + "selector": { + "reasoning_effort": { + "options": { + "low": "Low", + "medium": "Medium", + "high": "High" + } } }, "services": { diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index e8a8d6859c1..2bdf9947fe2 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -26,8 +26,8 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.async_ import run_callback_threadsafe diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index c228b6c1a14..de86e3d581f 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index 65005235c6b..ed704a61fed 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -33,6 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval = BASE_UPDATE_INTERVAL * (len(existing_coordinator_for_api_key) + 1) coordinator = OpenexchangeratesCoordinator( hass, + entry, async_get_clientsession(hass), api_key, base, diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 627e0d92e32..6245877ddbd 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -13,6 +13,7 @@ from aioopenexchangerates import ( OpenExchangeRatesClientError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,9 +24,12 @@ from .const import CLIENT_TIMEOUT, DOMAIN, LOGGER class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): """Represent a coordinator for Open Exchange Rates API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, session: ClientSession, api_key: str, base: str, @@ -33,7 +37,11 @@ class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): ) -> None: """Initialize the coordinator.""" super().__init__( - hass, LOGGER, name=f"{DOMAIN} base {base}", update_interval=update_interval + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} base {base}", + update_interval=update_interval, ) self.base = base self.client = Client(api_key, session) diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 55ca7bd2fb9..756823ff0ec 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,7 +19,7 @@ ATTRIBUTION = "Data provided by openexchangerates.org" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Open Exchange Rates sensor.""" quote: str = config_entry.data.get(CONF_QUOTE, "EUR") diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index 12c2f96d7e4..f1f080b30f8 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -24,8 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), ) open_garage_data_coordinator = OpenGarageDataUpdateCoordinator( - hass, - open_garage_connection=open_garage_connection, + hass, entry, open_garage_connection ) await open_garage_data_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = open_garage_data_coordinator diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 55cacfb5f90..33420ab3fd5 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -29,7 +29,9 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage binary sensors.""" open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py index 9f93e0fa716..64a4f2f20e7 100644 --- a/homeassistant/components/opengarage/button.py +++ b/homeassistant/components/opengarage/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -43,7 +43,7 @@ BUTTONS: tuple[OpenGarageButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage button entities.""" coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/opengarage/coordinator.py b/homeassistant/components/opengarage/coordinator.py index d35dc22d288..5d5440d6b1b 100644 --- a/homeassistant/components/opengarage/coordinator.py +++ b/homeassistant/components/opengarage/coordinator.py @@ -8,6 +8,7 @@ from typing import Any import opengarage +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -20,10 +21,12 @@ _LOGGER = logging.getLogger(__name__) class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Opengarage data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, + config_entry: ConfigEntry, open_garage_connection: opengarage.OpenGarage, ) -> None: """Initialize global Opengarage data updater.""" @@ -32,6 +35,7 @@ class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=5), ) diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 9623050c090..859e3382772 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -25,7 +25,9 @@ STATES_MAP = {0: CoverState.CLOSED, 1: CoverState.OPEN} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage covers.""" async_add_entities( diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index 003e0e0fa5a..14d14dd5d23 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -59,7 +59,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage sensors.""" open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 30801a59436..4aa334da3a7 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/openhome/config_flow.py b/homeassistant/components/openhome/config_flow.py index b495819211b..9cd6a79f012 100644 --- a/homeassistant/components/openhome/config_flow.py +++ b/homeassistant/components/openhome/config_flow.py @@ -3,13 +3,13 @@ import logging from typing import Any -from homeassistant.components.ssdp import ( +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN, SsdpServiceInfo, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME from .const import DOMAIN diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index c9143c977ce..9f8840b8487 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Openhome config entry.""" @@ -67,11 +67,9 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[ ] -def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> ( - Callable[ - [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] - ] -): +def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> Callable[ + [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] +]: """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index bbe4fdac3b3..cc210866e64 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index eb8435751c0..19d19f19a54 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -16,8 +16,8 @@ from homeassistant.components.air_quality import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index c95dc1283a4..c69cade5842 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OpenSkyError as exc: raise ConfigEntryNotReady from exc - coordinator = OpenSkyDataUpdateCoordinator(hass, client) + coordinator = OpenSkyDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 867a4781265..5e53a805753 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALTITUDE, diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py index f54e01b0006..f9aab88c904 100644 --- a/homeassistant/components/opensky/coordinator.py +++ b/homeassistant/components/opensky/coordinator.py @@ -36,11 +36,14 @@ class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, opensky: OpenSky) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, opensky: OpenSky + ) -> None: """Initialize the OpenSky data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval={ True: timedelta(seconds=90), @@ -50,11 +53,11 @@ class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): self._opensky = opensky self._previously_tracked: set[str] | None = None self._bounding_box = OpenSky.get_bounding_box( - self.config_entry.data[CONF_LATITUDE], - self.config_entry.data[CONF_LONGITUDE], - self.config_entry.options[CONF_RADIUS], + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + config_entry.options[CONF_RADIUS], ) - self._altitude = self.config_entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE) + self._altitude = config_entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE) async def _async_update_data(self) -> int: try: diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 9d317ae3e0d..0ab5b49f086 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -16,7 +16,7 @@ from .coordinator import OpenSkyDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8c92c70ab49..87da159872d 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -9,8 +9,7 @@ import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, ATTR_ID, @@ -21,21 +20,12 @@ from homeassistant.const import ( CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_STOP, - PRECISION_HALVES, - PRECISION_TENTHS, - PRECISION_WHOLE, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CH_OVRD, @@ -44,9 +34,6 @@ from .const import ( ATTR_LEVEL, ATTR_TRANSP_ARG, ATTR_TRANSP_CMD, - CONF_CLIMATE, - CONF_FLOOR_TEMP, - CONF_PRECISION, CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, DATA_GATEWAYS, @@ -70,29 +57,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0 -CLIMATE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] - ), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: cv.schema_with_slug_keys( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -118,35 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - dev_reg = dr.async_get(hass) - if ( - migrate_device := dev_reg.async_get_device( - {(DOMAIN, config_entry.data[CONF_ID])} - ) - ) is not None: - dev_reg.async_update_device( - migrate_device.id, - new_identifiers={ - ( - DOMAIN, - f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}", - ) - }, - ) - - # Migration can be removed in 2025.4.0 - ent_reg = er.async_get(hass) - if ( - entity_id := ent_reg.async_get_entity_id( - CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID] - ) - ) is not None: - ent_reg.async_update_entity( - entity_id, - new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity", - ) - config_entry.add_update_listener(options_updated) try: @@ -164,33 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the OpenTherm Gateway component.""" - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_import_from_configuration_yaml", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_import_from_configuration_yaml", - ) - if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: - conf = config[DOMAIN] - for device_id, device_config in conf.items(): - device_config[CONF_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config - ) - ) - return True - - def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 5d542bedc07..8e73392da05 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BOILER_DEVICE_DESCRIPTION, @@ -393,7 +393,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[OpenThermBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway binary sensors.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py index 00b91ad33e0..046b44bfa8c 100644 --- a/homeassistant/components/opentherm_gw/button.py +++ b/homeassistant/components/opentherm_gw/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import ( @@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS: tuple[OpenThermButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway buttons.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index e8aa99f7325..c69151c293a 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import ( @@ -50,7 +50,7 @@ class OpenThermClimateEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an OpenTherm Gateway climate entity.""" ents = [] diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 80c16ee88e1..a100dcb730f 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -25,7 +25,7 @@ from homeassistant.const import ( PRECISION_WHOLE, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN from .const import ( @@ -95,19 +95,6 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Handle manual initiation of the config flow.""" return await self.async_step_init(user_input) - # Deprecated import from configuration.yaml, can be removed in 2025.4.0 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import an OpenTherm Gateway device as a config entry. - - This flow is triggered by `async_setup` for configured devices. - """ - formatted_config = { - CONF_NAME: import_data.get(CONF_NAME, import_data[CONF_ID]), - CONF_DEVICE: import_data[CONF_DEVICE], - CONF_ID: import_data[CONF_ID], - } - return await self.async_step_init(info=formatted_config) - def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: """Show the config flow form with possible errors.""" return self.async_show_form( diff --git a/homeassistant/components/opentherm_gw/select.py b/homeassistant/components/opentherm_gw/select.py index cee1632dc48..da3fa1e80ec 100644 --- a/homeassistant/components/opentherm_gw/select.py +++ b/homeassistant/components/opentherm_gw/select.py @@ -20,7 +20,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import ( @@ -234,7 +234,7 @@ SELECT_DESCRIPTIONS: tuple[OpenThermSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway select entities.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 5ccb4166665..f9ac1b272be 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BOILER_DEVICE_DESCRIPTION, @@ -875,7 +875,7 @@ SENSOR_DESCRIPTIONS: tuple[OpenThermSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway sensors.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 77c7e3ab40a..cc57a7d9e0c 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -15,7 +15,7 @@ }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "id_exists": "Gateway id already exists", + "id_exists": "Gateway ID already exists", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } @@ -354,12 +354,6 @@ } } }, - "issues": { - "deprecated_import_from_configuration_yaml": { - "title": "Deprecated configuration", - "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml." - } - }, "options": { "step": { "init": { @@ -379,13 +373,13 @@ "fields": { "gateway_id": { "name": "Gateway ID", - "description": "The gateway_id of the OpenTherm Gateway." + "description": "The ID of the OpenTherm Gateway." } } }, "set_central_heating_ovrd": { "name": "Set central heating override", - "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.", + "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -393,7 +387,7 @@ }, "ch_override": { "name": "Central heating override", - "description": "The desired boolean value for the central heating override." + "description": "Whether to enable or disable the override." } } }, diff --git a/homeassistant/components/opentherm_gw/switch.py b/homeassistant/components/opentherm_gw/switch.py index 41ffa03a932..873675f0211 100644 --- a/homeassistant/components/opentherm_gw/switch.py +++ b/homeassistant/components/opentherm_gw/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION @@ -48,7 +48,7 @@ SWITCH_DESCRIPTIONS: tuple[OpenThermSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway switches.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 018d91710df..f45404ce38e 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW @@ -25,7 +25,9 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: # Once we've successfully authenticated, we re-enable client request retries: """Set up an OpenUV sensor based on a config entry.""" diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index 32d502cb8ce..cc09161b3e9 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -38,6 +38,7 @@ class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=entry, name=name, update_method=update_method, request_refresh_debouncer=Debouncer( @@ -48,7 +49,6 @@ class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): ), ) - self._entry = entry self.latitude = latitude self.longitude = longitude diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 742017be639..5b681655e2b 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UV_INDEX, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime from .const import ( @@ -166,7 +166,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a OpenUV sensor based on a config entry.""" coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 33cd23c4f6c..40ddf0ff37e 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -8,17 +8,10 @@ import logging from pyopenweathermap import create_owm_client from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_LANGUAGE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, -) +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant -from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS from .coordinator import WeatherUpdateCoordinator from .repairs import async_create_issue, async_delete_issue from .utils import build_data_and_options @@ -43,20 +36,16 @@ async def async_setup_entry( """Set up OpenWeatherMap as config entry.""" name = entry.data[CONF_NAME] api_key = entry.data[CONF_API_KEY] - latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) - longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] - if mode == OWM_MODE_V25: + if mode not in OWM_MODES: async_create_issue(hass, entry.entry_id) else: async_delete_issue(hass, entry.entry_id) owm_client = create_owm_client(api_key, mode, lang=language) - weather_coordinator = WeatherUpdateCoordinator( - owm_client, latitude, longitude, hass - ) + weather_coordinator = WeatherUpdateCoordinator(hass, entry, owm_client) await weather_coordinator.async_config_entry_first_refresh() @@ -69,7 +58,9 @@ async def async_setup_entry( return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: OpenweathermapConfigEntry +) -> bool: """Migrate old entry.""" config_entries = hass.config_entries data = entry.data @@ -79,7 +70,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) if version < 5: - combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + combined_data = {**data, **options, CONF_MODE: DEFAULT_OWM_MODE} new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( entry, @@ -93,7 +84,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, entry: OpenweathermapConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 8d33e117287..4c66778119e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONFIG_FLOW_VERSION, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 81a6544c7ce..fbd2cb1aee2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code" ATTR_API_CLOUD_COVERAGE = "cloud_coverage" ATTR_API_FORECAST = "forecast" ATTR_API_CURRENT = "current" +ATTR_API_MINUTE_FORECAST = "minute_forecast" ATTR_API_HOURLY_FORECAST = "hourly_forecast" ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" @@ -61,10 +62,8 @@ FORECAST_MODE_ONECALL_DAILY = "onecall_daily" OWM_MODE_FREE_CURRENT = "current" OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" -OWM_MODE_V25 = "v2.5" OWM_MODES = [ OWM_MODE_V30, - OWM_MODE_V25, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST, ] diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 3ef0eda0c8f..994949b5e03 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -1,12 +1,16 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import TYPE_CHECKING from pyopenweathermap import ( CurrentWeather, DailyWeatherForecast, HourlyWeatherForecast, + MinutelyWeatherForecast, OWMClient, RequestError, WeatherReport, @@ -17,20 +21,28 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, Forecast, ) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from . import OpenweathermapConfigEntry + from .const import ( ATTR_API_CLOUDS, ATTR_API_CONDITION, ATTR_API_CURRENT, ATTR_API_DAILY_FORECAST, + ATTR_API_DATETIME, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, + ATTR_API_PRECIPITATION, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -56,20 +68,25 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" + config_entry: OpenweathermapConfigEntry + def __init__( self, - owm_client: OWMClient, - latitude, - longitude, hass: HomeAssistant, + config_entry: OpenweathermapConfigEntry, + owm_client: OWMClient, ) -> None: """Initialize coordinator.""" self._owm_client = owm_client - self._latitude = latitude - self._longitude = longitude + self._latitude = config_entry.data.get(CONF_LATITUDE, hass.config.latitude) + self._longitude = config_entry.data.get(CONF_LONGITUDE, hass.config.longitude) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=WEATHER_UPDATE_INTERVAL, ) async def _async_update_data(self): @@ -94,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return { ATTR_API_CURRENT: current_weather, + ATTR_API_MINUTE_FORECAST: ( + self._get_minute_weather_data(weather_report.minutely_forecast) + if weather_report.minutely_forecast is not None + else {} + ), ATTR_API_HOURLY_FORECAST: [ self._get_hourly_forecast_weather_data(item) for item in weather_report.hourly_forecast @@ -104,6 +126,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ], } + def _get_minute_weather_data( + self, minute_forecast: list[MinutelyWeatherForecast] + ) -> dict: + """Get minute weather data from the forecast.""" + return { + ATTR_API_FORECAST: [ + { + ATTR_API_DATETIME: item.date_time, + ATTR_API_PRECIPITATION: round(item.precipitation, 2), + } + for item in minute_forecast + ] + } + def _get_current_weather_data(self, current_weather: CurrentWeather): return { ATTR_API_CONDITION: self._get_condition(current_weather.condition.id), diff --git a/homeassistant/components/openweathermap/icons.json b/homeassistant/components/openweathermap/icons.json new file mode 100644 index 00000000000..d493b1538ba --- /dev/null +++ b/homeassistant/components/openweathermap/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_minute_forecast": { + "service": "mdi:weather-snowy-rainy" + } + } +} diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index 14313a5a77e..88510aaae8c 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", "loggers": ["pyopenweathermap"], - "requirements": ["pyopenweathermap==0.2.1"] + "requirements": ["pyopenweathermap==0.2.2"] } diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index c54484e1e1e..2bde5750ca4 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -1,14 +1,18 @@ """Issues for OpenWeatherMap.""" -from typing import cast +from __future__ import annotations + +from typing import TYPE_CHECKING, cast from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir +if TYPE_CHECKING: + from . import OpenweathermapConfigEntry + from .const import DOMAIN, OWM_MODE_V30 from .utils import validate_api_key @@ -16,7 +20,7 @@ from .utils import validate_api_key class DeprecatedV25RepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: OpenweathermapConfigEntry) -> None: """Create flow.""" super().__init__() self.entry = entry diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 46789f4b3d2..0afab69b638 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -156,7 +156,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: OpenweathermapConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OpenWeatherMap sensor entities based on a config entry.""" domain_data = config_entry.runtime_data diff --git a/homeassistant/components/openweathermap/services.yaml b/homeassistant/components/openweathermap/services.yaml new file mode 100644 index 00000000000..6bbcf1b23e4 --- /dev/null +++ b/homeassistant/components/openweathermap/services.yaml @@ -0,0 +1,5 @@ +get_minute_forecast: + target: + entity: + domain: weather + integration: openweathermap diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 46b5feab75c..1aa161c87dc 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -47,5 +47,16 @@ } } } + }, + "services": { + "get_minute_forecast": { + "name": "Get minute forecast", + "description": "Retrieves a minute-by-minute weather forecast for one hour." + } + }, + "exceptions": { + "service_minute_forecast_mode": { + "message": "Minute forecast is available only when {name} mode is set to v3.0" + } } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 3a134a0ee26..12d883c871a 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -14,9 +14,11 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenweathermapConfigEntry from .const import ( @@ -28,6 +30,7 @@ from .const import ( ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_VISIBILITY_DISTANCE, @@ -39,16 +42,17 @@ from .const import ( DOMAIN, MANUFACTURER, OWM_MODE_FREE_FORECAST, - OWM_MODE_V25, OWM_MODE_V30, ) from .coordinator import WeatherUpdateCoordinator +SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast" + async def async_setup_entry( hass: HomeAssistant, config_entry: OpenweathermapConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OpenWeatherMap weather entity based on a config entry.""" domain_data = config_entry.runtime_data @@ -61,6 +65,14 @@ async def async_setup_entry( async_add_entities([owm_weather], False) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + name=SERVICE_GET_MINUTE_FORECAST, + schema=None, + func="async_get_minute_forecast", + supports_response=SupportsResponse.ONLY, + ) + class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" @@ -91,8 +103,9 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) + self.mode = mode - if mode in (OWM_MODE_V30, OWM_MODE_V25): + if mode == OWM_MODE_V30: self._attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY @@ -100,6 +113,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina elif mode == OWM_MODE_FREE_FORECAST: self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY + async def async_get_minute_forecast(self) -> dict[str, list[dict]] | dict: + """Return Minute forecast.""" + + if self.mode == OWM_MODE_V30: + return self.coordinator.data[ATTR_API_MINUTE_FORECAST] + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_minute_forecast_mode", + translation_placeholders={"name": DEFAULT_NAME}, + ) + @property def condition(self) -> str | None: """Return the current condition.""" diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index d2ee2e2dfbd..66f35a51b87 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 1a34d0547aa..23c8e7a8136 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -2,31 +2,26 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" - coordinator = OpowerCoordinator(hass, entry.data) + coordinator = OpowerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 629dce0823c..aed89ccf46e 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -2,19 +2,18 @@ from datetime import datetime, timedelta import logging -from types import MappingProxyType -from typing import Any, cast +from typing import cast from opower import ( Account, AggregateType, CostRead, Forecast, - InvalidAuth, MeterType, Opower, ReadResolution, ) +from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -23,30 +22,36 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) +type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] + class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Handle fetching Opower data, updating sensors and inserting statistics.""" + config_entry: OpowerConfigEntry + def __init__( self, hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + config_entry: OpowerConfigEntry, ) -> None: """Initialize the data handler.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Opower", # Data is updated daily on Opower. # Refresh every 12h to be at most 12h behind. @@ -54,10 +59,10 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) self.api = Opower( aiohttp_client.async_get_clientsession(hass), - entry_data[CONF_UTILITY], - entry_data[CONF_USERNAME], - entry_data[CONF_PASSWORD], - entry_data.get(CONF_TOTP_SECRET), + config_entry.data[CONF_UTILITY], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data.get(CONF_TOTP_SECRET), ) @callback @@ -80,8 +85,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # assume previous session has expired and re-login. await self.api.async_login() except InvalidAuth as err: + _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err - forecasts: list[Forecast] = await self.api.async_get_forecast() + except CannotConnect as err: + _LOGGER.error("Error during login: %s", err) + raise UpdateFailed(f"Error during login: {err}") from err + try: + forecasts: list[Forecast] = await self.api.async_get_forecast() + except ApiException as err: + _LOGGER.error("Error getting forecasts: %s", err) + raise _LOGGER.debug("Updating sensor data with: %s", forecasts) # Because Opower provides historical usage/cost with a delay of a couple of days # we need to insert data into statistics. @@ -90,7 +103,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): async def _insert_statistics(self) -> None: """Insert Opower statistics.""" - for account in await self.api.async_get_accounts(): + try: + accounts = await self.api.async_get_accounts() + except ApiException as err: + _LOGGER.error("Error getting accounts: %s", err) + raise + for account in accounts: id_prefix = "_".join( ( self.api.utility.subdomain(), @@ -252,9 +270,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): start = datetime.fromtimestamp(start_time, tz=tz) - timedelta(days=30) end = dt_util.now(tz) _LOGGER.debug("Getting monthly cost reads: %s - %s", start, end) - cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.BILL, start, end - ) + try: + cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + except ApiException as err: + _LOGGER.error("Error getting monthly cost reads: %s", err) + raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) if account.read_resolution == ReadResolution.BILLING: return cost_reads @@ -267,9 +289,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): assert start start = max(start, end - timedelta(days=3 * 365)) _LOGGER.debug("Getting daily cost reads: %s - %s", start, end) - daily_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.DAY, start, end - ) + try: + daily_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end + ) + except ApiException as err: + _LOGGER.error("Error getting daily cost reads: %s", err) + raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) if account.read_resolution == ReadResolution.DAY: @@ -281,9 +307,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): assert start start = max(start, end - timedelta(days=2 * 30)) _LOGGER.debug("Getting hourly cost reads: %s - %s", start, end) - hourly_cost_reads = await self.api.async_get_cost_reads( - account, AggregateType.HOUR, start, end - ) + try: + hourly_cost_reads = await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end + ) + except ApiException as err: + _LOGGER.error("Error getting hourly cost reads: %s", err) + raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) _update_with_finer_cost_reads(cost_reads, hourly_cost_reads) _LOGGER.debug("Got %s cost reads", len(cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index bd68cc84d13..2da4511c0aa 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.7"] + "requirements": ["opower==0.9.0"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 05a22dfbf1b..46aa9e9b318 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import date from opower import Forecast, MeterType, UnitOfMeasure @@ -13,23 +14,22 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator @dataclass(frozen=True, kw_only=True) class OpowerEntityDescription(SensorEntityDescription): """Class describing Opower sensors entities.""" - value_fn: Callable[[Forecast], str | float] + value_fn: Callable[[Forecast], str | float | date] # suggested_display_precision=0 for all sensors since @@ -183,11 +183,13 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: OpowerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Opower sensor.""" - coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[OpowerSensor] = [] forecasts = coordinator.data.values() for forecast in forecasts: @@ -245,7 +247,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): self.utility_account_id = utility_account_id @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | date: """Return the state.""" if self.coordinator.data is not None: return self.entity_description.value_fn( diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index da2993d1996..e804f06faa3 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oralb/config_flow.py b/homeassistant/components/oralb/config_flow.py index ab5d919194e..bac2d32bb2f 100644 --- a/homeassistant/components/oralb/config_flow.py +++ b/homeassistant/components/oralb/config_flow.py @@ -72,7 +72,7 @@ class OralBConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 9994bfc6443..3b345f4b36a 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import OralBConfigEntry @@ -108,7 +108,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: OralBConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OralB BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index 213350db6a4..450c56ae50e 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index 2f990333cf6..211abc838e7 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_SWITCHES, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/osoenergy/binary_sensor.py b/homeassistant/components/osoenergy/binary_sensor.py index 0cf0ac74d36..a2ba61ccbe4 100644 --- a/homeassistant/components/osoenergy/binary_sensor.py +++ b/homeassistant/components/osoenergy/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import OSOEnergyEntity @@ -45,7 +45,9 @@ SENSOR_TYPES: dict[str, OSOEnergyBinarySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy binary sensor.""" osoenergy: OSOEnergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py index 40ec33e3e02..18859627952 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -138,7 +138,9 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy sensor.""" osoenergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index b8f95c021fa..7e10168d941 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -2,15 +2,15 @@ "config": { "step": { "user": { - "title": "OSO Energy Auth", - "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "title": "OSO Energy auth", + "description": "Enter the 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth": { - "title": "OSO Energy Auth", - "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "title": "OSO Energy auth", + "description": "Enter a new 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } @@ -95,11 +95,11 @@ "services": { "get_profile": { "name": "Get heater profile", - "description": "Get the temperature profile of water heater" + "description": "Gets the temperature profile for water heater" }, "set_profile": { "name": "Set heater profile", - "description": "Set the temperature profile of water heater", + "description": "Sets the temperature profile for water heater", "fields": { "hour_00": { "name": "00:00", @@ -201,7 +201,7 @@ }, "set_v40_min": { "name": "Set v40 min", - "description": "Set the minimum quantity of water at 40°C for a heater", + "description": "Sets the minimum quantity of water at 40°C for a heater", "fields": { "v40_min": { "name": "V40 Min", @@ -211,21 +211,21 @@ }, "turn_off": { "name": "Turn off heating", - "description": "Turn off heating for one hour or until min temperature is reached", + "description": "Turns off heating for one hour or until min temperature is reached", "fields": { "until_temp_limit": { "name": "Until temperature limit", - "description": "Choose if heating should be off until min temperature (True) is reached or for one hour (False)" + "description": "Whether heating should be off until the minimum temperature is reached instead of for one hour." } } }, "turn_on": { "name": "Turn on heating", - "description": "Turn on heating for one hour or until max temperature is reached", + "description": "Turns on heating for one hour or until max temperature is reached", "fields": { "until_temp_limit": { "name": "Until temperature limit", - "description": "Choose if heating should be on until max temperature (True) is reached or for one hour (False)" + "description": "Whether heating should be on until the maximum temperature is reached instead of for one hour." } } } diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index ff117d6577d..07820ee97d5 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -19,8 +19,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import DOMAIN @@ -49,7 +49,9 @@ SERVICE_TURN_ON = "turn_on" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy heater based on a config entry.""" osoenergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 6ddd392af7b..25380810862 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -24,10 +24,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 4b95be1d40d..0756f32ab18 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -7,16 +7,20 @@ import logging import aiohttp import python_otbr_api +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) from homeassistant.components.thread import async_add_dataset -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from . import websocket_api +from . import homeassistant_hardware, websocket_api from .const import DOMAIN +from .types import OTBRConfigEntry from .util import ( GetBorderAgentIdNotSupported, OTBRData, @@ -28,12 +32,13 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type OTBRConfigEntry = ConfigEntry[OTBRData] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" websocket_api.async_setup(hass) + + async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + return True @@ -77,6 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool entry.async_on_unload(entry.add_update_listener(async_reload_entry)) entry.runtime_data = otbrdata + if fw_info := await homeassistant_hardware.async_get_firmware_info(hass, entry): + await async_notify_firmware_info(hass, DOMAIN, fw_info) + return True diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index aff79ca4651..514f6c7617c 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -16,7 +16,12 @@ import yarl from homeassistant.components.hassio import AddonError, AddonManager from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_HASSIO, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -201,12 +206,23 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): # we have to assume it's the first version # This check can be removed in HA Core 2025.9 unique_id = discovery_info.uuid + + if unique_id != discovery_info.uuid: + continue + if ( - unique_id != discovery_info.uuid - or current_url.host != config["host"] + current_url.host != config["host"] or current_url.port == config["port"] ): + # Reload the entry since OTBR has restarted + if current_entry.state == ConfigEntryState.LOADED: + assert current_entry.unique_id is not None + await self.hass.config_entries.async_reload( + current_entry.entry_id + ) + continue + # Update URL with the new port self.hass.config_entries.async_update_entry( current_entry, diff --git a/homeassistant/components/otbr/homeassistant_hardware.py b/homeassistant/components/otbr/homeassistant_hardware.py new file mode 100644 index 00000000000..94193be1359 --- /dev/null +++ b/homeassistant/components/otbr/homeassistant_hardware.py @@ -0,0 +1,76 @@ +"""Home Assistant Hardware firmware utilities.""" + +from __future__ import annotations + +import logging + +from yarl import URL + +from homeassistant.components.hassio import AddonManager +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, + OwningIntegration, + get_otbr_addon_firmware_info, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.hassio import is_hassio + +from .const import DOMAIN +from .types import OTBRConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_firmware_info( + hass: HomeAssistant, config_entry: OTBRConfigEntry +) -> FirmwareInfo | None: + """Return firmware information for the OpenThread Border Router.""" + owners: list[OwningIntegration | OwningAddon] = [ + OwningIntegration(config_entry_id=config_entry.entry_id) + ] + + device = None + + if is_hassio(hass) and (host := URL(config_entry.data["url"]).host) is not None: + otbr_addon_manager = AddonManager( + hass=hass, + logger=_LOGGER, + addon_name="OpenThread Border Router", + addon_slug=host.replace("-", "_"), + ) + + if ( + addon_fw_info := await get_otbr_addon_firmware_info( + hass, otbr_addon_manager + ) + ) is not None: + device = addon_fw_info.device + owners.extend(addon_fw_info.owners) + + firmware_version = None + + if config_entry.state in ( + # This function is called during OTBR config entry setup so we need to account + # for both config entry states + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_IN_PROGRESS, + ): + try: + firmware_version = await config_entry.runtime_data.get_coprocessor_version() + except HomeAssistantError: + firmware_version = None + + if device is None: + return None + + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.SPINEL, + firmware_version=firmware_version, + source=DOMAIN, + owners=owners, + ) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index ca0faa160f0..f4029f4aa9e 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0"] + "requirements": ["python-otbr-api==2.7.0"] } diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index e1afa5b8909..3a9661c454d 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -5,7 +5,7 @@ "data": { "url": "[%key:common::config_flow::data::url%]" }, - "description": "Provide URL for the Open Thread Border Router's REST API" + "description": "Provide URL for the OpenThread Border Router's REST API" } }, "error": { @@ -20,8 +20,8 @@ }, "issues": { "get_get_border_agent_id_unsupported": { - "title": "The OTBR does not support border agent ID", - "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR." + "title": "The OTBR does not support Border Agent ID", + "description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR." }, "insecure_thread_network": { "title": "Insecure Thread network settings detected", diff --git a/homeassistant/components/otbr/types.py b/homeassistant/components/otbr/types.py new file mode 100644 index 00000000000..eff6aa980d6 --- /dev/null +++ b/homeassistant/components/otbr/types.py @@ -0,0 +1,7 @@ +"""The Open Thread Border Router integration types.""" + +from homeassistant.config_entries import ConfigEntry + +from .util import OTBRData + +type OTBRConfigEntry = ConfigEntry[OTBRData] diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 351e23c7736..30e456e11a8 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -163,6 +163,11 @@ class OTBRData: """Get extended address (EUI-64).""" return await self.api.get_extended_address() + @_handle_otbr_error + async def get_coprocessor_version(self) -> str: + """Get coprocessor firmware version.""" + return await self.api.get_coprocessor_version() + async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 255bc0ded34..af508d2e915 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -20,7 +20,9 @@ TIME_STEP = 30 # Default time step assumed by Google Authenticator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OTP sensor.""" diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index 5086a5cfc9b..a83430b3531 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidLoginException: return False - coordinator = OurGroceriesDataUpdateCoordinator(hass, og) + coordinator = OurGroceriesDataUpdateCoordinator(hass, entry, og) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index bc645b2bdb3..a822931e88c 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -8,6 +8,7 @@ import logging from ourgroceries import OurGroceries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -21,7 +22,11 @@ _LOGGER = logging.getLogger(__name__) class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - def __init__(self, hass: HomeAssistant, og: OurGroceries) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, og: OurGroceries + ) -> None: """Initialize global OurGroceries data updater.""" self.og = og self.lists: list[dict] = [] @@ -30,6 +35,7 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, ) diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 5b8d19e5aa1..f257ef481c7 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -11,7 +11,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,7 +19,9 @@ from .coordinator import OurGroceriesDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OurGroceries todo platform config entry.""" coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 51efb52e55d..8aa1ed0e4fe 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -39,7 +39,6 @@ from .const import ( LOGGER, OVERKIZ_DEVICE_TO_PLATFORM, PLATFORMS, - UPDATE_INTERVAL, UPDATE_INTERVAL_ALL_ASSUMED_STATE, UPDATE_INTERVAL_LOCAL, ) @@ -104,13 +103,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) coordinator = OverkizDataUpdateCoordinator( hass, + entry, LOGGER, - name="device events", client=client, devices=setup.devices, places=setup.root_place, - update_interval=UPDATE_INTERVAL, - config_entry_id=entry.entry_id, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index 90c135291c3..1a5490dd329 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -19,7 +19,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .coordinator import OverkizDataUpdateCoordinator @@ -209,7 +209,7 @@ SUPPORTED_DEVICES = {description.key: description for description in ALARM_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz alarm control panel from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 3a75cd77c2f..09319d59932 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -143,7 +143,7 @@ SUPPORTED_STATES = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz binary sensors from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 92711ac8ca8..f4e051ef9ca 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -100,7 +100,7 @@ SUPPORTED_COMMANDS = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz button from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 1398bb7c25a..058c3aefdb7 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -10,7 +10,7 @@ from pyoverkiz.enums.ui import UIWidget from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import OverkizDataConfigEntry from .atlantic_electrical_heater import AtlanticElectricalHeater @@ -25,6 +25,7 @@ from .atlantic_pass_apc_heat_pump_main_component import ( from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone +from .evo_home_controller import EvoHomeController from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP from .hitachi_air_to_water_heating_zone import HitachiAirToWaterHeatingZone @@ -53,6 +54,7 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: HitachiAirToWaterHeatingZone, + UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, @@ -80,7 +82,7 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz climate from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py index 5ba9dabe038..eff1d5fa130 100644 --- a/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py +++ b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py @@ -5,7 +5,7 @@ from __future__ import annotations from asyncio import sleep from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.climate import ( diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py new file mode 100644 index 00000000000..e0cb8be7380 --- /dev/null +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -0,0 +1,101 @@ +"""Support for EvoHomeController.""" + +from datetime import timedelta + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.util import dt as dt_util + +from ..entity import OverkizDataUpdateCoordinator, OverkizEntity + +PRESET_DAY_OFF = "day-off" +PRESET_HOLIDAYS = "holidays" + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTO: HVACMode.AUTO, + OverkizCommandParam.OFF: HVACMode.OFF, +} +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.DAY_OFF: PRESET_DAY_OFF, + OverkizCommandParam.HOLIDAYS: PRESET_HOLIDAYS, +} +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} + + +class EvoHomeController(OverkizEntity, ClimateEntity): + """Representation of EvoHomeController device.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "EvoHome" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if state := self.device.states.get(OverkizState.RAMSES_RAMSES_OPERATING_MODE): + operating_mode = state.value_as_str + + if operating_mode in OVERKIZ_TO_HVAC_MODES: + return OVERKIZ_TO_HVAC_MODES[operating_mode] + + if operating_mode in OVERKIZ_TO_PRESET_MODES: + return HVACMode.OFF + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, HVAC_MODES_TO_OVERKIZ[hvac_mode] + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.RAMSES_RAMSES_OPERATING_MODE] + ) and state.value_as_str in OVERKIZ_TO_PRESET_MODES: + return OVERKIZ_TO_PRESET_MODES[state.value_as_str] + + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_DAY_OFF: + today_end_of_day = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + time_interval = today_end_of_day + + if preset_mode == PRESET_HOLIDAYS: + one_week_from_now = dt_util.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=7) + time_interval = one_week_from_now + + await self.executor.async_execute_command( + OverkizCommand.SET_OPERATING_MODE, + PRESET_MODES_TO_OVERKIZ[preset_mode], + time_interval.strftime("%Y/%m/%d %H:%M"), + ) diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index 8410e50873d..c5465128bba 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -119,5 +119,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature) + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) ) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 9a94c30d95d..af955e5fb95 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -23,7 +23,6 @@ from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -34,6 +33,8 @@ from homeassistant.const import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER @@ -273,7 +274,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" hostname = discovery_info.hostname @@ -284,7 +285,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): return await self._process_discovery(gateway_id) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle ZeroConf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 41b567500a9..7f5f4ad85bd 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -102,6 +102,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) + UIWidget.EVO_HOME_CONTROLLER: Platform.CLIMATE, # widgetName, uiClass is EvoHome (not supported) UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_AIR_TO_WATER_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.HITACHI_DHW: Platform.WATER_HEATER, # widgetName, uiClass is HitachiHeatingSystem (not supported) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 484ef138cf7..4b79cfc9c06 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import ClientConnectorError, ServerDisconnectedError from pyoverkiz.client import OverkizClient @@ -26,7 +26,10 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.decorator import Registry -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER +if TYPE_CHECKING: + from . import OverkizDataConfigEntry + +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL EVENT_HANDLERS: Registry[ str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]] @@ -36,26 +39,26 @@ EVENT_HANDLERS: Registry[ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Class to manage fetching data from Overkiz platform.""" + config_entry: OverkizDataConfigEntry _default_update_interval: timedelta def __init__( self, hass: HomeAssistant, + config_entry: OverkizDataConfigEntry, logger: logging.Logger, *, - name: str, client: OverkizClient, devices: list[Device], places: Place | None, - update_interval: timedelta, - config_entry_id: str, ) -> None: """Initialize global data updater.""" super().__init__( hass, logger, - name=name, - update_interval=update_interval, + config_entry=config_entry, + name="device events", + update_interval=UPDATE_INTERVAL, ) self.data = {} @@ -63,8 +66,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): self.devices: dict[str, Device] = {d.device_url: d for d in devices} self.executions: dict[str, dict[str, str]] = {} self.areas = self._places_to_area(places) if places else None - self.config_entry_id = config_entry_id - self._default_update_interval = update_interval + self._default_update_interval = UPDATE_INTERVAL self.is_stateless = all( device.protocol in (Protocol.RTS, Protocol.INTERNAL) @@ -164,7 +166,7 @@ async def on_device_created_updated( ) -> None: """Handle device unavailable / disabled event.""" coordinator.hass.async_create_task( - coordinator.hass.config_entries.async_reload(coordinator.config_entry_id) + coordinator.hass.config_entries.async_reload(coordinator.config_entry.entry_id) ) diff --git a/homeassistant/components/overkiz/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py index 38c02eba1bb..dd3216f9c10 100644 --- a/homeassistant/components/overkiz/cover/__init__.py +++ b/homeassistant/components/overkiz/cover/__init__.py @@ -4,7 +4,7 @@ from pyoverkiz.enums import OverkizCommand, UIClass from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import OverkizDataConfigEntry from .awning import Awning @@ -15,7 +15,7 @@ from .vertical_cover import LowSpeedCover, VerticalCover async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz covers from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/light.py b/homeassistant/components/overkiz/light.py index 933d4cf695b..acd63140196 100644 --- a/homeassistant/components/overkiz/light.py +++ b/homeassistant/components/overkiz/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .coordinator import OverkizDataUpdateCoordinator @@ -24,7 +24,7 @@ from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz lights from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/lock.py b/homeassistant/components/overkiz/lock.py index 1c073d2f9aa..16ec32b0667 100644 --- a/homeassistant/components/overkiz/lock.py +++ b/homeassistant/components/overkiz/lock.py @@ -9,7 +9,7 @@ from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.lock import LockEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .entity import OverkizEntity @@ -18,7 +18,7 @@ from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz locks from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index eda39821d5c..07ec02d76a6 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.15.5"], + "requirements": ["pyoverkiz==1.16.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 0e03e822424..83c0e7cf7a8 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -191,7 +191,7 @@ SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz number from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/scene.py b/homeassistant/components/overkiz/scene.py index 4533ed3245c..bd362b4b372 100644 --- a/homeassistant/components/overkiz/scene.py +++ b/homeassistant/components/overkiz/scene.py @@ -9,7 +9,7 @@ from pyoverkiz.models import Scenario from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry @@ -17,7 +17,7 @@ from . import OverkizDataConfigEntry async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz scenes from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index ac467eaaa7a..e23dafdaab8 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -10,7 +10,7 @@ from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -129,7 +129,7 @@ SUPPORTED_STATES = {description.key: description for description in SELECT_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz select from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 84d25b01d24..9214398a37b 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import OverkizDataConfigEntry @@ -483,7 +483,7 @@ SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz sensors from a config entry.""" data = entry.runtime_data @@ -534,8 +534,7 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): # This is probably incorrect and should be fixed in a follow up PR. # To ensure measurement sensors do not get an `unknown` state on # a falsy value (e.g. 0 or 0.0) we also check the state_class. - or self.state_class != SensorStateClass.MEASUREMENT - and not state.value + or (self.state_class != SensorStateClass.MEASUREMENT and not state.value) ): return None diff --git a/homeassistant/components/overkiz/siren.py b/homeassistant/components/overkiz/siren.py index f7246e50ec0..af761611444 100644 --- a/homeassistant/components/overkiz/siren.py +++ b/homeassistant/components/overkiz/siren.py @@ -12,7 +12,7 @@ from homeassistant.components.siren import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .entity import OverkizEntity @@ -21,7 +21,7 @@ from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz sirens from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index c921dbab776..d14b2792947 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .entity import OverkizDescriptiveEntity @@ -110,7 +110,7 @@ SUPPORTED_DEVICES = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz switch from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 1dd1d596a33..9895ea84c2c 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -6,7 +6,7 @@ from pyoverkiz.enums.ui import UIWidget from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import OverkizDataConfigEntry from ..entity import OverkizEntity @@ -21,7 +21,7 @@ from .hitachi_dhw import HitachiDHW async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz DHW from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py index abd3f40adc2..f5a9e3d4a7e 100644 --- a/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py +++ b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py @@ -64,10 +64,8 @@ class DomesticHotWaterProduction(OverkizEntity, WaterHeaterEntity): for param, mode in OVERKIZ_TO_OPERATION_MODE.items(): # Filter only for mode allowed by this device # or allow all if no mode definition found - if ( - not state_mode_definition - or state_mode_definition.values - and param in state_mode_definition.values + if not state_mode_definition or ( + state_mode_definition.values and param in state_mode_definition.values ): self.operation_mode_to_overkiz[mode] = param self._attr_operation_list.append(param) diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index c16b02739ed..597d44f66cf 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from typing import cast from aiohttp.hdrs import METH_POST from aiohttp.web_request import Request from aiohttp.web_response import Response from python_overseerr import OverseerrConnectionError +from homeassistant.components import cloud from homeassistant.components.webhook import ( async_generate_url, async_register, @@ -17,14 +19,16 @@ from homeassistant.components.webhook import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.http import HomeAssistantView from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS +from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator from .services import setup_services -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] +CONF_CLOUDHOOK_URL = "cloudhook_url" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -63,6 +67,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_remove_entry(hass: HomeAssistant, entry: OverseerrConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Overseerr cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + class OverseerrWebhookManager: """Overseerr webhook manager.""" @@ -85,6 +101,8 @@ class OverseerrWebhookManager: for url in urls: if url not in res: res.append(url) + if CONF_CLOUDHOOK_URL in self.entry.data: + res.append(self.entry.data[CONF_CLOUDHOOK_URL]) return res async def register_webhook(self) -> None: @@ -98,18 +116,18 @@ class OverseerrWebhookManager: allowed_methods=[METH_POST], ) if not await self.check_need_change(): + self.entry.runtime_data.push = True return for url in self.webhook_urls: - if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): - LOGGER.debug("Setting Overseerr webhook to %s", url) - await self.client.set_webhook_notification_config( - enabled=True, - types=REGISTERED_NOTIFICATIONS, - webhook_url=url, - json_payload=JSON_PAYLOAD, - ) + if await self.test_and_set_webhook(url): return - LOGGER.error("Failed to set Overseerr webhook") + LOGGER.info("Failed to register Overseerr webhook") + if cloud.async_active_subscription(self.hass): + LOGGER.info("Trying to register a cloudhook URL") + url = await _async_cloudhook_generate_url(self.hass, self.entry) + if await self.test_and_set_webhook(url): + return + LOGGER.error("Failed to register Overseerr cloudhook") async def check_need_change(self) -> bool: """Check if webhook needs to be changed.""" @@ -121,6 +139,20 @@ class OverseerrWebhookManager: or current_config.types != REGISTERED_NOTIFICATIONS ) + async def test_and_set_webhook(self, url: str) -> bool: + """Test and set webhook.""" + if await self.client.test_webhook_notification_config(url, JSON_PAYLOAD): + LOGGER.debug("Setting Overseerr webhook to %s", url) + await self.client.set_webhook_notification_config( + enabled=True, + types=REGISTERED_NOTIFICATIONS, + webhook_url=url, + json_payload=JSON_PAYLOAD, + ) + self.entry.runtime_data.push = True + return True + return False + async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> Response: @@ -129,8 +161,22 @@ class OverseerrWebhookManager: LOGGER.debug("Received webhook payload: %s", data) if data["notification_type"].startswith("MEDIA"): await self.entry.runtime_data.async_refresh() + async_dispatcher_send(hass, EVENT_KEY, data) return HomeAssistantView.json({"message": "ok"}) async def unregister_webhook(self) -> None: """Unregister webhook.""" async_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + + +async def _async_cloudhook_generate_url( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_id = entry.data[CONF_WEBHOOK_ID] + webhook_url = await cloud.async_create_cloudhook(hass, webhook_id) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return cast(str, entry.data[CONF_CLOUDHOOK_URL]) diff --git a/homeassistant/components/overseerr/config_flow.py b/homeassistant/components/overseerr/config_flow.py index 2ad0c8d6d61..9a8bdd1676f 100644 --- a/homeassistant/components/overseerr/config_flow.py +++ b/homeassistant/components/overseerr/config_flow.py @@ -1,14 +1,18 @@ """Config flow for Overseerr.""" +from collections.abc import Mapping from typing import Any -from python_overseerr import OverseerrClient -from python_overseerr.exceptions import OverseerrError +from python_overseerr import ( + OverseerrAuthenticationError, + OverseerrClient, + OverseerrError, +) import voluptuous as vol from yarl import URL from homeassistant.components.webhook import async_generate_id -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -25,6 +29,25 @@ from .const import DOMAIN class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): """Overseerr config flow.""" + async def _check_connection( + self, host: str, port: int, ssl: bool, api_key: str + ) -> str | None: + """Check if we can connect to the Overseerr instance.""" + client = OverseerrClient( + host, + port, + api_key, + ssl=ssl, + session=async_get_clientsession(self.hass), + ) + try: + await client.get_request_count() + except OverseerrAuthenticationError: + return "invalid_auth" + except OverseerrError: + return "cannot_connect" + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -38,26 +61,32 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: host}) port = url.port assert port - client = OverseerrClient( - host, - port, - user_input[CONF_API_KEY], - ssl=url.scheme == "https", - session=async_get_clientsession(self.hass), + error = await self._check_connection( + host, port, url.scheme == "https", user_input[CONF_API_KEY] ) - try: - await client.get_request_count() - except OverseerrError: - errors["base"] = "cannot_connect" + if error: + errors["base"] = error else: - return self.async_create_entry( - title="Overseerr", + if self.source == SOURCE_USER: + return self.async_create_entry( + title="Overseerr", + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: url.scheme == "https", + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_WEBHOOK_ID: async_generate_id(), + }, + ) + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, data={ + **reconfigure_entry.data, CONF_HOST: host, CONF_PORT: port, CONF_SSL: url.scheme == "https", CONF_API_KEY: user_input[CONF_API_KEY], - CONF_WEBHOOK_ID: async_generate_id(), }, ) return self.async_show_form( @@ -67,3 +96,41 @@ class OverseerrConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-auth confirmation.""" + errors: dict[str, str] = {} + if user_input: + entry = self._get_reauth_entry() + error = await self._check_connection( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + user_input[CONF_API_KEY], + ) + if error: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user() diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 48f5436c336..2aa0879ffed 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -14,6 +14,8 @@ ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" +EVENT_KEY = f"{DOMAIN}_event" + REGISTERED_NOTIFICATIONS = ( NotificationType.REQUEST_PENDING_APPROVAL | NotificationType.REQUEST_APPROVED @@ -23,28 +25,24 @@ REGISTERED_NOTIFICATIONS = ( | NotificationType.REQUEST_AUTOMATICALLY_APPROVED ) JSON_PAYLOAD = ( - '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"event\\":\\"' - '{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa' - 'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"' - ':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\' - '":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu' - 's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":' - '\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}' - '\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ' - 'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting' - 's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB' - 'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId' - '}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty' - 'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",' - '\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern' - 'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep' - 'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported' - 'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":' - '\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c' - 'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":' - '\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented' - 'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}' - '\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di' - 'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented' - 'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"' + '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' + '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_id\\":\\"{{media_tmdbid}}\\",\\"t' + 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' + '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' + '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' + 'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a' + 'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco' + 'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_' + 'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_' + 'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte' + 'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported' + 'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported' + '_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by' + '_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{' + 'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema' + 'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us' + 'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b' + 'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by' + '_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"' ) diff --git a/homeassistant/components/overseerr/coordinator.py b/homeassistant/components/overseerr/coordinator.py index 79ad738c037..2149dcbec7c 100644 --- a/homeassistant/components/overseerr/coordinator.py +++ b/homeassistant/components/overseerr/coordinator.py @@ -2,12 +2,18 @@ from datetime import timedelta -from python_overseerr import OverseerrClient, RequestCount -from python_overseerr.exceptions import OverseerrConnectionError +from python_overseerr import ( + OverseerrAuthenticationError, + OverseerrClient, + OverseerrConnectionError, + RequestCount, +) +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -30,18 +36,28 @@ class OverseerrCoordinator(DataUpdateCoordinator[RequestCount]): config_entry=entry, update_interval=timedelta(minutes=5), ) + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + ssl = entry.data[CONF_SSL] self.client = OverseerrClient( - entry.data[CONF_HOST], - entry.data[CONF_PORT], + host, + port, entry.data[CONF_API_KEY], - ssl=entry.data[CONF_SSL], + ssl=ssl, session=async_get_clientsession(hass), ) + self.url = URL.build(host=host, port=port, scheme="https" if ssl else "http") + self.push = False async def _async_update_data(self) -> RequestCount: """Fetch data from API endpoint.""" try: return await self.client.get_request_count() + except OverseerrAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err except OverseerrConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/overseerr/diagnostics.py b/homeassistant/components/overseerr/diagnostics.py new file mode 100644 index 00000000000..d45e1441e23 --- /dev/null +++ b/homeassistant/components/overseerr/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Overseerr.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import CONF_CLOUDHOOK_URL +from .coordinator import OverseerrConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OverseerrConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data + + data = entry.runtime_data + + return { + "has_cloudhooks": has_cloudhooks, + "coordinator_data": asdict(data.data), + } diff --git a/homeassistant/components/overseerr/entity.py b/homeassistant/components/overseerr/entity.py index 6e835347736..937ad52f7ec 100644 --- a/homeassistant/components/overseerr/entity.py +++ b/homeassistant/components/overseerr/entity.py @@ -18,5 +18,6 @@ class OverseerrEntity(CoordinatorEntity[OverseerrCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, entry_type=DeviceEntryType.SERVICE, + configuration_url=coordinator.url, ) self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py new file mode 100644 index 00000000000..1ffb1e71771 --- /dev/null +++ b/homeassistant/components/overseerr/event.py @@ -0,0 +1,125 @@ +"""Support for Overseerr events.""" + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import DOMAIN, EVENT_KEY +from .coordinator import OverseerrConfigEntry, OverseerrCoordinator +from .entity import OverseerrEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OverseerrEventEntityDescription(EventEntityDescription): + """Describes Overseerr config event entity.""" + + nullable_fields: list[str] + + +EVENTS: tuple[OverseerrEventEntityDescription, ...] = ( + OverseerrEventEntityDescription( + key="media", + translation_key="last_media_event", + event_types=[ + "pending", + "approved", + "available", + "failed", + "declined", + "auto_approved", + ], + nullable_fields=["comment", "issue"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OverseerrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Overseerr sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + ent_reg = er.async_get(hass) + + event_entities_setup_before = ent_reg.async_get_entity_id( + Platform.EVENT, DOMAIN, f"{entry.entry_id}-media" + ) + + if coordinator.push or event_entities_setup_before: + async_add_entities( + OverseerrEvent(coordinator, description) for description in EVENTS + ) + + +class OverseerrEvent(OverseerrEntity, EventEntity): + """Defines a Overseerr event entity.""" + + entity_description: OverseerrEventEntityDescription + + def __init__( + self, + coordinator: OverseerrCoordinator, + description: OverseerrEventEntityDescription, + ) -> None: + """Initialize Overseerr event entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_available = True + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update) + ) + + async def _handle_update(self, event: dict[str, Any]) -> None: + """Handle incoming event.""" + event_type = event["notification_type"].lower() + if event_type.split("_")[0] == self.entity_description.key: + self._attr_entity_picture = event.get("image") + self._trigger_event( + event_type[6:], + parse_event(event, self.entity_description.nullable_fields), + ) + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + if super().available != self._attr_available: + self._attr_available = super().available + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._attr_available and self.coordinator.push + + +def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]: + """Parse event.""" + event.pop("notification_type") + event.pop("image") + for field in nullable_fields: + event.pop(field) + if (media := event.get("media")) is not None: + for field in ("status", "status4k"): + media[field] = media[field].lower() + for field in ("tmdb_id", "tvdb_id"): + if (value := media.get(field)) != "": + media[field] = int(value) + else: + media[field] = None + if (request := event.get("request")) is not None: + request["request_id"] = int(request["request_id"]) + return event diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json index 2876eb5f882..af18836680b 100644 --- a/homeassistant/components/overseerr/icons.json +++ b/homeassistant/components/overseerr/icons.json @@ -22,6 +22,11 @@ "available_requests": { "default": "mdi:message-bulleted" } + }, + "event": { + "last_media_event": { + "default": "mdi:multimedia" + } } }, "services": { diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index ddcf9ccce5e..3c4321ebb37 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -1,12 +1,13 @@ { "domain": "overseerr", "name": "Overseerr", + "after_dependencies": ["cloud"], "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/overseerr", "integration_type": "service", "iot_class": "local_push", - "quality_scale": "bronze", - "requirements": ["python-overseerr==0.5.0"] + "quality_scale": "platinum", + "requirements": ["python-overseerr==0.7.1"] } diff --git a/homeassistant/components/overseerr/quality_scale.yaml b/homeassistant/components/overseerr/quality_scale.yaml index 144f5c1977c..7afbcd6aa07 100644 --- a/homeassistant/components/overseerr/quality_scale.yaml +++ b/homeassistant/components/overseerr/quality_scale.yaml @@ -37,11 +37,11 @@ rules: status: done comment: Handled by the coordinator parallel-updates: done - reauthentication-flow: todo - test-coverage: todo + reauthentication-flow: done + test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | @@ -50,24 +50,30 @@ rules: status: exempt comment: | This integration does not support discovery. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | This integration has a fixed single device. - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo + entity-category: done + entity-device-class: + status: exempt + comment: | + This integration has no relevant device class to use. + entity-disabled-by-default: + status: exempt + comment: | + This integration has no unpopular entities to disable. entity-translations: done exception-translations: done - icon-translations: todo - reconfiguration-flow: todo + icon-translations: done + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py index 2daaa3de0cb..510e6f52c59 100644 --- a/homeassistant/components/overseerr/sensor.py +++ b/homeassistant/components/overseerr/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import REQUESTS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator @@ -76,7 +76,7 @@ SENSORS: tuple[OverseerrSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: OverseerrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Overseerr sensor entities based on a config entry.""" diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 338c9d91a38..14650fd5c25 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -10,17 +10,45 @@ "url": "The URL of the Overseerr instance.", "api_key": "The API key of the Overseerr instance." } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::overseerr::config::step::user::data_description::api_key%]" + } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Authentication failed. Your API key is invalid or CSRF protection is turned on, preventing authentication.", "invalid_host": "The provided URL is not a valid host." } }, "entity": { + "event": { + "last_media_event": { + "name": "Last media event", + "state_attributes": { + "event_type": { + "state": { + "pending": "Pending", + "approved": "Approved", + "available": "Available", + "failed": "Failed", + "declined": "Declined", + "auto_approved": "Auto-approved" + } + } + } + } + }, "sensor": { "total_requests": { "name": "Total requests" @@ -49,6 +77,9 @@ "connection_error": { "message": "Error connecting to the Overseerr instance: {error}" }, + "auth_error": { + "message": "Invalid API key." + }, "not_loaded": { "message": "{target} is not loaded." }, diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 8cada86da34..1dc12c7f008 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -111,7 +111,9 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OVO Energy sensor based on a config entry.""" coordinator: DataUpdateCoordinator[OVODailyUsage] = hass.data[DOMAIN][ diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index 3dc11e3a601..9d8e449e1d1 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -16,10 +16,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "account": "OVO account id (only add if you have multiple accounts)" + "account": "OVO account ID (only add if you have multiple accounts)" }, "description": "Set up an OVO Energy instance to access your energy usage.", - "title": "Add OVO Energy Account" + "title": "Add OVO Energy account" }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 720c3718a4f..623e5e17b66 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 6a6f0f078b1..7ccbbb69aa1 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -16,14 +16,16 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN as OT_DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OwnTracks based off an entry.""" # Restore previously loaded devices diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index d2ccc83972a..e12c092453c 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -2,23 +2,20 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import LOGGER -from .coordinator import P1MonitorDataUpdateCoordinator +from .coordinator import P1MonitorConfigEntry, P1MonitorDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator] - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: P1MonitorConfigEntry) -> bool: """Set up P1 Monitor from a config entry.""" - coordinator = P1MonitorDataUpdateCoordinator(hass) + coordinator = P1MonitorDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: @@ -31,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: P1MonitorConfigEntry +) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", config_entry.version) @@ -54,6 +53,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: P1MonitorConfigEntry) -> bool: """Unload P1 Monitor config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/p1_monitor/coordinator.py b/homeassistant/components/p1_monitor/coordinator.py index 5459f88c388..3be78f8efd5 100644 --- a/homeassistant/components/p1_monitor/coordinator.py +++ b/homeassistant/components/p1_monitor/coordinator.py @@ -30,6 +30,8 @@ from .const import ( SERVICE_WATERMETER, ) +type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator] + class P1MonitorData(TypedDict): """Class for defining data in dict.""" @@ -43,17 +45,19 @@ class P1MonitorData(TypedDict): class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): """Class to manage fetching P1 Monitor data from single endpoint.""" - config_entry: ConfigEntry + config_entry: P1MonitorConfigEntry has_water_meter: bool | None = None def __init__( self, hass: HomeAssistant, + config_entry: P1MonitorConfigEntry, ) -> None: """Initialize global P1 Monitor data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index d2e2ec5c24e..ac670486e79 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -6,7 +6,6 @@ from dataclasses import asdict from typing import TYPE_CHECKING, Any, cast from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -16,6 +15,7 @@ from .const import ( SERVICE_SMARTMETER, SERVICE_WATERMETER, ) +from .coordinator import P1MonitorConfigEntry if TYPE_CHECKING: from _typeshed import DataclassInstance @@ -24,7 +24,7 @@ TO_REDACT = {CONF_HOST, CONF_PORT} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: P1MonitorConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data = { diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 771ef0e19af..15a8f510fd7 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CURRENCY_EURO, @@ -22,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -33,7 +32,7 @@ from .const import ( SERVICE_SMARTMETER, SERVICE_WATERMETER, ) -from .coordinator import P1MonitorDataUpdateCoordinator +from .coordinator import P1MonitorConfigEntry, P1MonitorDataUpdateCoordinator SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -236,7 +235,9 @@ SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: P1MonitorConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up P1 Monitor Sensors based on a config entry.""" entities: list[P1MonitorSensorEntity] = [] @@ -290,7 +291,7 @@ class P1MonitorSensorEntity( def __init__( self, *, - entry: ConfigEntry, + entry: P1MonitorConfigEntry, description: SensorEntityDescription, name: str, service: Literal["smartmeter", "watermeter", "phases", "settings"], diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index f20b3d11261..a698cdcd8b7 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -7,13 +7,18 @@ from homeassistant.core import HomeAssistant from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: """Set up Palazzetti from a config entry.""" - coordinator = PalazzettiDataUpdateCoordinator(hass) + coordinator = PalazzettiDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py new file mode 100644 index 00000000000..319a1174542 --- /dev/null +++ b/homeassistant/components/palazzetti/button.py @@ -0,0 +1,51 @@ +"""Support for Palazzetti buttons.""" + +from __future__ import annotations + +from pypalazzetti.exceptions import CommunicationError + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator +from .entity import PalazzettiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Palazzetti button platform.""" + + coordinator = config_entry.runtime_data + if coordinator.client.has_fan_silent: + async_add_entities([PalazzettiSilentButtonEntity(coordinator)]) + + +class PalazzettiSilentButtonEntity(PalazzettiEntity, ButtonEntity): + """Representation of a Palazzetti Silent button.""" + + _attr_translation_key = "silent" + + def __init__( + self, + coordinator: PalazzettiDataUpdateCoordinator, + ) -> None: + """Initialize a Palazzetti Silent button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-silent" + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.set_fan_silent() + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 356f3a7306f..5a4097e083a 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -13,18 +13,17 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PalazzettiConfigEntry -from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT -from .coordinator import PalazzettiDataUpdateCoordinator +from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity async def async_setup_entry( hass: HomeAssistant, entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti climates based on a config entry.""" async_add_entities([PalazzettiClimateEntity(entry.runtime_data)]) @@ -57,8 +56,6 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): self._attr_fan_modes = list( map(str, range(client.fan_speed_min, client.fan_speed_max + 1)) ) - if client.has_fan_silent: - self._attr_fan_modes.insert(0, FAN_SILENT) if client.has_fan_high: self._attr_fan_modes.append(FAN_HIGH) if client.has_fan_auto: @@ -124,15 +121,13 @@ class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return the fan mode.""" - api_state = self.coordinator.client.fan_speed + api_state = self.coordinator.client.current_fan_speed() return FAN_MODES[api_state] async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" try: - if fan_mode == FAN_SILENT: - await self.coordinator.client.set_fan_silent() - elif fan_mode == FAN_HIGH: + if fan_mode == FAN_HIGH: await self.coordinator.client.set_fan_high() elif fan_mode == FAN_AUTO: await self.coordinator.client.set_fan_auto() diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py index fe892b6624d..91762216ff5 100644 --- a/homeassistant/components/palazzetti/config_flow.py +++ b/homeassistant/components/palazzetti/config_flow.py @@ -6,10 +6,10 @@ from pypalazzetti.client import PalazzettiClient from pypalazzetti.exceptions import CommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER @@ -53,7 +53,7 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" diff --git a/homeassistant/components/palazzetti/const.py b/homeassistant/components/palazzetti/const.py index b2e27b2a6fd..1b68cf99f9d 100644 --- a/homeassistant/components/palazzetti/const.py +++ b/homeassistant/components/palazzetti/const.py @@ -18,7 +18,7 @@ ERROR_CANNOT_CONNECT = "cannot_connect" FAN_SILENT: Final = "silent" FAN_HIGH: Final = "high" FAN_AUTO: Final = "auto" -FAN_MODES: Final = [FAN_SILENT, "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] +FAN_MODES: Final = ["0", "1", "2", "3", "4", "5", FAN_HIGH, FAN_AUTO] STATUS_TO_HA: Final[dict[StateType, str]] = { 0: "off", diff --git a/homeassistant/components/palazzetti/coordinator.py b/homeassistant/components/palazzetti/coordinator.py index d992bd3fb62..1e4069e58ea 100644 --- a/homeassistant/components/palazzetti/coordinator.py +++ b/homeassistant/components/palazzetti/coordinator.py @@ -22,11 +22,13 @@ class PalazzettiDataUpdateCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, ) -> None: """Initialize global Palazzetti data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/palazzetti/diagnostics.py b/homeassistant/components/palazzetti/diagnostics.py index 3843f0ec111..e386ffc7833 100644 --- a/homeassistant/components/palazzetti/diagnostics.py +++ b/homeassistant/components/palazzetti/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PalazzettiConfigEntry +from .coordinator import PalazzettiConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/palazzetti/icons.json b/homeassistant/components/palazzetti/icons.json new file mode 100644 index 00000000000..c20a9572618 --- /dev/null +++ b/homeassistant/components/palazzetti/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "silent": { + "default": "mdi:volume-mute" + } + } + } +} diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 70e58507159..41e8e0fb4de 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.15"] + "requirements": ["pypalazzetti==0.1.19"] } diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py index 06114bfef54..63c1ed16f0c 100644 --- a/homeassistant/components/palazzetti/number.py +++ b/homeassistant/components/palazzetti/number.py @@ -3,25 +3,36 @@ from __future__ import annotations from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PalazzettiConfigEntry from .const import DOMAIN -from .coordinator import PalazzettiDataUpdateCoordinator +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti number platform.""" - async_add_entities([PalazzettiCombustionPowerEntity(config_entry.runtime_data)]) + + entities: list[PalazzettiEntity] = [ + PalazzettiCombustionPowerEntity(config_entry.runtime_data) + ] + + if config_entry.runtime_data.client.has_fan(FanType.LEFT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.LEFT)) + + if config_entry.runtime_data.client.has_fan(FanType.RIGHT): + entities.append(PalazzettiFanEntity(config_entry.runtime_data, FanType.RIGHT)) + + async_add_entities(entities) class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): @@ -64,3 +75,49 @@ class PalazzettiCombustionPowerEntity(PalazzettiEntity, NumberEntity): ) from err await self.coordinator.async_request_refresh() + + +class PalazzettiFanEntity(PalazzettiEntity, NumberEntity): + """Representation of Palazzetti number entity for Combustion power.""" + + _attr_device_class = NumberDeviceClass.WIND_SPEED + _attr_native_step = 1 + + def __init__( + self, coordinator: PalazzettiDataUpdateCoordinator, fan: FanType + ) -> None: + """Initialize the Palazzetti number entity.""" + super().__init__(coordinator) + self.fan = fan + + self._attr_translation_key = f"fan_{str.lower(fan.name)}_speed" + self._attr_native_min_value = coordinator.client.min_fan_speed(fan) + self._attr_native_max_value = coordinator.client.max_fan_speed(fan) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-fan_{str.lower(fan.name)}_speed" + ) + + @property + def native_value(self) -> float: + """Return the state of the setting entity.""" + return self.coordinator.client.current_fan_speed(self.fan) + + async def async_set_native_value(self, value: float) -> None: + """Update the setting.""" + try: + await self.coordinator.client.set_fan_speed(int(value), self.fan) + except CommunicationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + except ValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "name": str.lower(self.fan.name), + "value": str(value), + }, + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index 493b2595117..ff8461ad193 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -15,8 +15,8 @@ rules: comment: | This integration does not register actions. docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -35,7 +35,7 @@ rules: status: exempt comment: | This integration does not have configuration. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -51,12 +51,12 @@ rules: discovery-update-info: done discovery: done docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | @@ -67,9 +67,7 @@ rules: entity-translations: done exception-translations: done icon-translations: - status: exempt - comment: | - This integration does not have custom icons. + status: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index 11462201f4e..57d5ca861a2 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -10,12 +10,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import PalazzettiConfigEntry from .const import STATUS_TO_HA -from .coordinator import PalazzettiDataUpdateCoordinator +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity @@ -60,7 +59,7 @@ PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti sensor entities based on a config entry.""" diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index ad7bc498bd1..501ee777fe9 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -27,6 +27,9 @@ "invalid_fan_mode": { "message": "Fan mode {value} is invalid." }, + "invalid_fan_speed": { + "message": "Fan {name} speed {value} is invalid." + }, "invalid_target_temperature": { "message": "Target temperature {value} is invalid." }, @@ -38,6 +41,11 @@ } }, "entity": { + "button": { + "silent": { + "name": "Silent" + } + }, "climate": { "palazzetti": { "state_attributes": { @@ -54,6 +62,12 @@ "number": { "combustion_power": { "name": "Combustion power" + }, + "fan_left_speed": { + "name": "Left fan speed" + }, + "fan_right_speed": { + "name": "Right fan speed" } }, "sensor": { diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index a7cb0780ca9..b0e23031a24 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 69800d2ef1e..6dacc08077d 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import MediaPlayerState, MediaType from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 8738b897d29..a78920f33a5 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_DEVICE_INFO, @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV from a config entry.""" diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py index ad40a97f700..5fa4be9ca2b 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Remote from .const import ( @@ -28,7 +28,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV Remote from a config entry.""" diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 89ad6066f48..db9c35a7608 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/peblar/binary_sensor.py b/homeassistant/components/peblar/binary_sensor.py index e8e5095f050..8834a2ba2a0 100644 --- a/homeassistant/components/peblar/binary_sensor.py +++ b/homeassistant/components/peblar/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator from .entity import PeblarEntity @@ -50,7 +50,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar binary sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/button.py b/homeassistant/components/peblar/button.py index 22150c82649..8c60c8d84d3 100644 --- a/homeassistant/components/peblar/button.py +++ b/homeassistant/components/peblar/button.py @@ -15,7 +15,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator from .entity import PeblarEntity @@ -52,7 +52,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar buttons based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index 24248355f72..b9b42cd6ca5 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -9,7 +9,6 @@ from aiohttp import CookieJar from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -18,6 +17,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -27,7 +27,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _discovery_info: zeroconf.ZeroconfServiceInfo + _discovery_info: ZeroconfServiceInfo async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -128,7 +128,7 @@ class PeblarFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Peblar device.""" if not (sn := discovery_info.properties.get("sn")): diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index d7d7c2fa5b5..58fcc9b85da 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -23,7 +23,7 @@ PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = { ChargeLimiter.INSTALLATION_LIMIT: "installation_limit", ChargeLimiter.LOCAL_MODBUS_API: "local_modbus_api", ChargeLimiter.LOCAL_REST_API: "local_rest_api", - ChargeLimiter.LOCAL_SCHEDULED: "local_scheduled", + ChargeLimiter.LOCAL_SCHEDULED_CHARGING: "local_scheduled_charging", ChargeLimiter.OCPP_SMART_CHARGING: "ocpp_smart_charging", ChargeLimiter.OVERCURRENT_PROTECTION: "overcurrent_protection", ChargeLimiter.PHASE_IMBALANCE: "phase_imbalance", diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 058f2aefb3b..36708b207c5 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -34,6 +34,7 @@ class PeblarRuntimeData: """Class to hold runtime data.""" data_coordinator: PeblarDataUpdateCoordinator + last_known_charging_limit = 6 system_information: PeblarSystemInformation user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator @@ -137,6 +138,8 @@ class PeblarVersionDataUpdateCoordinator( class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): """Class to manage fetching Peblar active data.""" + config_entry: PeblarConfigEntry + def __init__( self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi ) -> None: diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index 6244945077b..a954d112c4a 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -36,6 +36,9 @@ } }, "switch": { + "charge": { + "default": "mdi:ev-plug-type2" + }, "force_single_phase": { "default": "mdi:power-cycle" } diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 859682d3f1d..e2ae96de988 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.3"], + "requirements": ["peblar==0.4.0"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index 1a7cec43295..bff1bb26db4 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -2,101 +2,129 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from peblar import PeblarApi - from homeassistant.components.number import ( NumberDeviceClass, - NumberEntity, NumberEntityDescription, + RestoreNumber, ) -from homeassistant.const import EntityCategory, UnitOfElectricCurrent -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EntityCategory, + UnitOfElectricCurrent, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ( - PeblarConfigEntry, - PeblarData, - PeblarDataUpdateCoordinator, - PeblarRuntimeData, -) +from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator from .entity import PeblarEntity from .helpers import peblar_exception_handler PARALLEL_UPDATES = 1 -@dataclass(frozen=True, kw_only=True) -class PeblarNumberEntityDescription(NumberEntityDescription): - """Describe a Peblar number.""" - - native_max_value_fn: Callable[[PeblarRuntimeData], int] - set_value_fn: Callable[[PeblarApi, float], Awaitable[Any]] - value_fn: Callable[[PeblarData], int | None] - - -DESCRIPTIONS = [ - PeblarNumberEntityDescription( - key="charge_current_limit", - translation_key="charge_current_limit", - device_class=NumberDeviceClass.CURRENT, - entity_category=EntityCategory.CONFIG, - native_step=1, - native_min_value=6, - native_max_value_fn=lambda x: x.user_configuration_coordinator.data.user_defined_charge_limit_current, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - set_value_fn=lambda x, v: x.ev_interface(charge_current_limit=int(v) * 1000), - value_fn=lambda x: round(x.ev.charge_current_limit / 1000), - ), -] - - async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar number based on a config entry.""" async_add_entities( - PeblarNumberEntity( - entry=entry, - coordinator=entry.runtime_data.data_coordinator, - description=description, - ) - for description in DESCRIPTIONS + [ + PeblarChargeCurrentLimitNumberEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + ) + ] ) -class PeblarNumberEntity( +class PeblarChargeCurrentLimitNumberEntity( PeblarEntity[PeblarDataUpdateCoordinator], - NumberEntity, + RestoreNumber, ): - """Defines a Peblar number.""" + """Defines a Peblar charge current limit number. - entity_description: PeblarNumberEntityDescription + This entity is a little bit different from the other entities, any value + below 6 amps is ignored. It means the Peblar is not charging. + Peblar has assigned a dual functionality to the charge current limit + number, it is used to set the current charging value and to start/stop/pauze + the charging process. + """ + + _attr_device_class = NumberDeviceClass.CURRENT + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = 6 + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_translation_key = "charge_current_limit" def __init__( self, entry: PeblarConfigEntry, coordinator: PeblarDataUpdateCoordinator, - description: PeblarNumberEntityDescription, ) -> None: - """Initialize the Peblar entity.""" - super().__init__(entry=entry, coordinator=coordinator, description=description) - self._attr_native_max_value = description.native_max_value_fn( - entry.runtime_data + """Initialize the Peblar charge current limit entity.""" + super().__init__( + entry=entry, + coordinator=coordinator, + description=NumberEntityDescription(key="charge_current_limit"), ) + configuration = entry.runtime_data.user_configuration_coordinator.data + self._attr_native_max_value = configuration.user_defined_charge_limit_current - @property - def native_value(self) -> int | None: - """Return the number value.""" - return self.entity_description.value_fn(self.coordinator.data) + async def async_added_to_hass(self) -> None: + """Load the last known state when adding this entity.""" + if ( + (last_state := await self.async_get_last_state()) + and (last_number_data := await self.async_get_last_number_data()) + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and last_number_data.native_value + ): + self._attr_native_value = last_number_data.native_value + # Set the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + last_number_data.native_value + ) + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update. + + Ignore any update that provides a ampere value that is below the + minimum value (6 amps). It means the Peblar is currently not charging. + """ + if ( + current_charge_limit := round( + self.coordinator.data.ev.charge_current_limit / 1000 + ) + ) < 6: + return + self._attr_native_value = current_charge_limit + # Update the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = ( + current_charge_limit + ) + super()._handle_coordinator_update() @peblar_exception_handler async def async_set_native_value(self, value: float) -> None: - """Change to new number value.""" - await self.entity_description.set_value_fn(self.coordinator.api, value) + """Change the current charging value.""" + # If charging is currently disabled (below 6 amps), just set the value + # as the native value and the last known charging limit in the runtime + # data. So we can pick it up once charging gets enabled again. + if self.coordinator.data.ev.charge_current_limit < 6000: + self._attr_native_value = int(value) + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + value + ) + self.async_write_ha_state() + return + await self.coordinator.api.ev_interface(charge_current_limit=int(value) * 1000) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py index a2a0997a797..17503951ccd 100644 --- a/homeassistant/components/peblar/select.py +++ b/homeassistant/components/peblar/select.py @@ -11,7 +11,7 @@ from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator from .entity import PeblarEntity @@ -49,7 +49,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar select based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py index e655253d75c..81476eef9aa 100644 --- a/homeassistant/components/peblar/sensor.py +++ b/homeassistant/components/peblar/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import ( @@ -231,7 +231,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index fffa2b08d85..416f1a2c062 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -96,6 +96,7 @@ "installation_limit": "Installation limit", "local_modbus_api": "Modbus API", "local_rest_api": "REST API", + "local_scheduled_charging": "Scheduled charging", "ocpp_smart_charging": "OCPP smart charging", "overcurrent_protection": "Overcurrent protection", "phase_imbalance": "Phase imbalance", @@ -106,7 +107,7 @@ "cp_state": { "name": "State", "state": { - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "fault": "Fault", "invalid": "Invalid", @@ -152,6 +153,9 @@ } }, "switch": { + "charge": { + "name": "Charge" + }, "force_single_phase": { "name": "Force single phase" } diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py index e56c2fcdaec..f2e1ae13ae2 100644 --- a/homeassistant/components/peblar/switch.py +++ b/homeassistant/components/peblar/switch.py @@ -6,12 +6,12 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from peblar import PeblarApi +from peblar import PeblarEVInterface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( PeblarConfigEntry, @@ -31,7 +31,19 @@ class PeblarSwitchEntityDescription(SwitchEntityDescription): has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True is_on_fn: Callable[[PeblarData], bool] - set_fn: Callable[[PeblarApi, bool], Awaitable[Any]] + set_fn: Callable[[PeblarDataUpdateCoordinator, bool], Awaitable[Any]] + + +def _async_peblar_charge( + coordinator: PeblarDataUpdateCoordinator, on: bool +) -> Awaitable[PeblarEVInterface]: + """Set the charge state.""" + charge_current_limit = 0 + if on: + charge_current_limit = ( + coordinator.config_entry.runtime_data.last_known_charging_limit * 1000 + ) + return coordinator.api.ev_interface(charge_current_limit=charge_current_limit) DESCRIPTIONS = [ @@ -44,7 +56,14 @@ DESCRIPTIONS = [ and x.user_configuration_coordinator.data.connected_phases > 1 ), is_on_fn=lambda x: x.ev.force_single_phase, - set_fn=lambda x, on: x.ev_interface(force_single_phase=on), + set_fn=lambda x, on: x.api.ev_interface(force_single_phase=on), + ), + PeblarSwitchEntityDescription( + key="charge", + translation_key="charge", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda x: (x.ev.charge_current_limit >= 6000), + set_fn=_async_peblar_charge, ), ] @@ -52,7 +71,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar switch based on a config entry.""" async_add_entities( @@ -82,11 +101,11 @@ class PeblarSwitchEntity( @peblar_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_fn(self.coordinator.api, True) + await self.entity_description.set_fn(self.coordinator, True) await self.coordinator.async_request_refresh() @peblar_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.set_fn(self.coordinator.api, False) + await self.entity_description.set_fn(self.coordinator, False) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 9e132da63bc..88966916069 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -11,7 +11,7 @@ from homeassistant.components.update import ( UpdateEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( PeblarConfigEntry, @@ -37,14 +37,14 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( key="firmware", device_class=UpdateDeviceClass.FIRMWARE, installed_fn=lambda x: x.current.firmware, - has_fn=lambda x: x.current.firmware is not None, + has_fn=lambda x: x.available.firmware is not None, available_fn=lambda x: x.available.firmware, ), PeblarUpdateEntityDescription( key="customization", translation_key="customization", available_fn=lambda x: x.available.customization, - has_fn=lambda x: x.current.customization is not None, + has_fn=lambda x: x.available.customization is not None, installed_fn=lambda x: x.current.customization, ), ) @@ -53,7 +53,7 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar update based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py index a55f0fcc731..a4d59a8c9a2 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -24,7 +24,7 @@ PARALLEL_UPDATES: Final = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor for PECO.""" if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: diff --git a/homeassistant/components/peco/manifest.json b/homeassistant/components/peco/manifest.json index 698981e9361..7dc80c6f837 100644 --- a/homeassistant/components/peco/manifest.json +++ b/homeassistant/components/peco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/peco", "iot_class": "cloud_polling", - "requirements": ["peco==0.0.30"] + "requirements": ["peco==0.1.2"] } diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index d08947eb0ec..eafa36c98e9 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -76,7 +76,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 30e5f4d2a38..1c71603e41e 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -7,21 +7,18 @@ import logging from aiopegelonline import PegelOnline from aiopegelonline.const import CONNECT_ERRORS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool: """Set up PEGELONLINE entry.""" @@ -35,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) except CONNECT_ERRORS as err: raise ConfigEntryNotReady("Failed to connect") from err - coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) + coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) await coordinator.async_config_entry_first_refresh() @@ -46,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: PegelOnlineConfigEntry +) -> bool: """Unload PEGELONLINE entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py index c8233673fde..1e2471a59f2 100644 --- a/homeassistant/components/pegel_online/coordinator.py +++ b/homeassistant/components/pegel_online/coordinator.py @@ -4,6 +4,7 @@ import logging from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurements +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -11,12 +12,20 @@ from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES _LOGGER = logging.getLogger(__name__) +type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] + class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements]): """DataUpdateCoordinator for the pegel_online integration.""" + config_entry: PegelOnlineConfigEntry + def __init__( - self, hass: HomeAssistant, name: str, api: PegelOnline, station: Station + self, + hass: HomeAssistant, + config_entry: PegelOnlineConfigEntry, + api: PegelOnline, + station: Station, ) -> None: """Initialize the PegelOnlineDataUpdateCoordinator.""" self.api = api @@ -24,7 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=MIN_TIME_BETWEEN_UPDATES, ) diff --git a/homeassistant/components/pegel_online/diagnostics.py b/homeassistant/components/pegel_online/diagnostics.py index b68437c5ee7..e3b4a166cb4 100644 --- a/homeassistant/components/pegel_online/diagnostics.py +++ b/homeassistant/components/pegel_online/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PegelOnlineConfigEntry +from .coordinator import PegelOnlineConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 50eb80bafa8..fd90683a9b2 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -14,10 +14,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PegelOnlineConfigEntry -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity @@ -93,7 +92,7 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PegelOnlineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the PEGELONLINE sensor.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index d16c7e1600c..d9d89494bd9 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index 675a803ce91..441c6a2646e 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err # create the coordinator with the API object - coordinator = MyPermobilCoordinator(hass, p_api) + coordinator = MyPermobilCoordinator(hass, entry, p_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py index 4b768cf5af5..c2d51067e19 100644 --- a/homeassistant/components/permobil/binary_sensor.py +++ b/homeassistant/components/permobil/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MyPermobilCoordinator @@ -42,7 +42,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create and setup the binary sensor.""" diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 07ddefa9dce..e0fb55a0363 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -17,9 +17,8 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import selector +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py index 6efde26d341..ea7ddadff9f 100644 --- a/homeassistant/components/permobil/coordinator.py +++ b/homeassistant/components/permobil/coordinator.py @@ -7,6 +7,7 @@ import logging from mypermobil import MyPermobil, MyPermobilAPIException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,11 +26,16 @@ class MyPermobilData: class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): """MyPermobil coordinator.""" - def __init__(self, hass: HomeAssistant, p_api: MyPermobil) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, p_api: MyPermobil + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="permobil", update_interval=timedelta(minutes=5), ) diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index 54d3a61c519..5f8cb88290a 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -32,7 +32,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES from .coordinator import MyPermobilCoordinator @@ -175,7 +175,7 @@ DISTANCE_UNITS: dict[Any, UnitOfLength] = { async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create sensors from a config entry created in the integrations UI.""" diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index a5eb8bb4f4d..2871f4b575a 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from homeassistant.util.uuid import random_uuid_hex diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py index 431443d9139..8e0808f9879 100644 --- a/homeassistant/components/persistent_notification/trigger.py +++ b/homeassistant/components/persistent_notification/trigger.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index b793f4b33ae..856e07bb2ee 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -280,7 +280,7 @@ class PersonStorageCollection(collection.DictStorageCollection): return data @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" return info[CONF_NAME] diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py new file mode 100644 index 00000000000..7307ac2f801 --- /dev/null +++ b/homeassistant/components/pglab/__init__.py @@ -0,0 +1,85 @@ +"""PG LAB Electronics integration.""" + +from __future__ import annotations + +from pypglab.mqtt import ( + Client as PyPGLabMqttClient, + Sub_State as PyPGLabSubState, + Subcribe_CallBack as PyPGLabSubscribeCallBack, +) + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, LOGGER +from .discovery import PGLabDiscovery + +type PGLABConfigEntry = ConfigEntry[PGLabDiscovery] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: + """Set up PG LAB Electronics integration from a config entry.""" + + async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: + """Publish an MQTT message using the Home Assistant MQTT client.""" + await mqtt.async_publish(hass, topic, payload, qos, retain) + + async def mqtt_subscribe( + sub_state: PyPGLabSubState, topic: str, callback_func: PyPGLabSubscribeCallBack + ) -> PyPGLabSubState: + """Subscribe to MQTT topics using the Home Assistant MQTT client.""" + + @callback + def mqtt_message_received(msg: ReceiveMessage) -> None: + """Handle PGLab mqtt messages.""" + callback_func(msg.topic, msg.payload) + + topics = { + "pglab_subscribe_topic": { + "topic": topic, + "msg_callback": mqtt_message_received, + } + } + + sub_state = async_prepare_subscribe_topics(hass, sub_state, topics) + await async_subscribe_topics(hass, sub_state) + return sub_state + + async def mqtt_unsubscribe(sub_state: PyPGLabSubState) -> None: + async_unsubscribe_topics(hass, sub_state) + + if not await mqtt.async_wait_for_mqtt_client(hass): + LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + # Create an MQTT client for PGLab used for PGLab python module. + pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) + + # Setup PGLab device discovery. + entry.runtime_data = PGLabDiscovery() + + # Start to discovery PG Lab devices. + await entry.runtime_data.start(hass, pglab_mqtt, entry) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: + """Unload a config entry.""" + + # Stop PGLab device discovery. + pglab_discovery = entry.runtime_data + await pglab_discovery.stop(hass, entry) + + return True diff --git a/homeassistant/components/pglab/config_flow.py b/homeassistant/components/pglab/config_flow.py new file mode 100644 index 00000000000..606de757622 --- /dev/null +++ b/homeassistant/components/pglab/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for PG LAB Electronics integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import DISCOVERY_TOPIC, DOMAIN + + +class PGLabFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + + await self.async_set_unique_id(DOMAIN) + + # Validate the message, abort if it fails. + if not discovery_info.topic.endswith("/config"): + # Not a PGLab Electronics discovery message. + return self.async_abort(reason="invalid_discovery_info") + if not discovery_info.payload: + # Empty payload, unexpected payload. + return self.async_abort(reason="invalid_discovery_info") + + return await self.async_step_confirm_from_mqtt() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + try: + if not mqtt.is_connected(self.hass): + return self.async_abort(reason="mqtt_not_connected") + except KeyError: + return self.async_abort(reason="mqtt_not_configured") + + return await self.async_step_confirm_from_user() + + def step_confirm( + self, step_id: str, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + + if user_input is not None: + return self.async_create_entry( + title="PG LAB Electronics", + data={ + "discovery_prefix": DISCOVERY_TOPIC, + }, + ) + + return self.async_show_form(step_id=step_id) + + async def async_step_confirm_from_mqtt( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from MQTT discovered.""" + return self.step_confirm(step_id="confirm_from_mqtt", user_input=user_input) + + async def async_step_confirm_from_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from user add integration.""" + return self.step_confirm(step_id="confirm_from_user", user_input=user_input) diff --git a/homeassistant/components/pglab/const.py b/homeassistant/components/pglab/const.py new file mode 100644 index 00000000000..de076ac37f0 --- /dev/null +++ b/homeassistant/components/pglab/const.py @@ -0,0 +1,12 @@ +"""Constants used by PG LAB Electronics integration.""" + +import logging + +# The domain of the integration. +DOMAIN = "pglab" + +# The message logger. +LOGGER = logging.getLogger(__package__) + +# The MQTT message used to subscribe to get a new PG LAB device. +DISCOVERY_TOPIC = "pglab/discovery" diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py new file mode 100644 index 00000000000..d202d11d6e7 --- /dev/null +++ b/homeassistant/components/pglab/device_sensor.py @@ -0,0 +1,56 @@ +"""Device Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import callback + +if TYPE_CHECKING: + from .entity import PGLabEntity + + +class PGLabDeviceSensor: + """Keeps PGLab device sensor update.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device sensor.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors + + async def subscribe_topics(self): + """Subscribe to the device sensors topics.""" + self._sensors.set_on_state_callback(self.state_updated) + await self._sensors.subscribe_topics() + + def add_ha_sensor(self, entity: PGLabEntity) -> None: + """Add a new HA sensor to the list.""" + self._ha_sensors.append(entity) + + def remove_ha_sensor(self, entity: PGLabEntity) -> None: + """Remove a HA sensor from the list.""" + self._ha_sensors.remove(entity) + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # notify all HA sensors that PG LAB device sensor fields have been updated + for s in self._ha_sensors: + s.state_updated(payload) + + @property + def state(self) -> dict: + """Return the device sensors state.""" + return self._sensors.state + + @property + def sensors(self) -> PyPGLabSensors: + """Return the pypglab device sensors.""" + return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py new file mode 100644 index 00000000000..fec6f5ce40d --- /dev/null +++ b/homeassistant/components/pglab/discovery.py @@ -0,0 +1,303 @@ +"""Discovery PG LAB Electronics devices.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import json +from typing import TYPE_CHECKING, Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.mqtt import Client as PyPGLabMqttClient + +from homeassistant.components.mqtt import ( + EntitySubscription, + ReceiveMessage, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity + +from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER +from .device_sensor import PGLabDeviceSensor + +if TYPE_CHECKING: + from . import PGLABConfigEntry + +# Supported platforms. +PLATFORMS = [ + Platform.SENSOR, + Platform.SWITCH, +] + +# Used to create a new component entity. +CREATE_NEW_ENTITY = { + Platform.SENSOR: "pglab_create_new_entity_sensor", + Platform.SWITCH: "pglab_create_new_entity_switch", +} + + +class PGLabDiscoveryError(Exception): + """Raised when a discovery has failed.""" + + +def get_device_id_from_discovery_topic(topic: str) -> str | None: + """From the discovery topic get the PG LAB Electronics device id.""" + + # The discovery topic has the following format "pglab/discovery/[Device ID]/config" + split_topic = topic.split("/", 5) + + # Do a sanity check on the string. + if len(split_topic) != 4: + return None + + if split_topic[3] != "config": + return None + + return split_topic[2] + + +class DiscoverDeviceInfo: + """Keeps information of the PGLab discovered device.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device discovery info.""" + + # Hash string represents the devices actual configuration, + # it depends on the number of available relays and shutters. + # When the hash string changes the devices entities must be rebuilt. + self._hash = pglab_device.hash + self._entities: list[tuple[str, str]] = [] + self._sensors = PGLabDeviceSensor(pglab_device) + + def add_entity(self, entity: Entity) -> None: + """Add an entity.""" + + # PGLabEntity always have unique IDs + if TYPE_CHECKING: + assert entity.unique_id is not None + self._entities.append((entity.platform.domain, entity.unique_id)) + + @property + def hash(self) -> int: + """Return the hash for this configuration.""" + return self._hash + + @property + def entities(self) -> list[tuple[str, str]]: + """Return array of entities available.""" + return self._entities + + @property + def sensors(self) -> PGLabDeviceSensor: + """Return the PGLab device sensor.""" + return self._sensors + + +async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: + """Create a new DiscoverDeviceInfo instance.""" + discovery_info = DiscoverDeviceInfo(pglab_device) + + # Subscribe to sensor state changes. + await discovery_info.sensors.subscribe_topics() + return discovery_info + + +@dataclass +class PGLabDiscovery: + """Discovery a PGLab device with the following MQTT topic format pglab/discovery/[device]/config.""" + + def __init__(self) -> None: + """Initialize the discovery class.""" + self._substate: dict[str, EntitySubscription] = {} + self._discovery_topic = DISCOVERY_TOPIC + self._mqtt_client = None + self._discovered: dict[str, DiscoverDeviceInfo] = {} + self._disconnect_platform: list = [] + + async def __build_device( + self, mqtt: PyPGLabMqttClient, msg: ReceiveMessage + ) -> PyPGLabDevice: + """Build a PGLab device.""" + + # Check if the discovery message is in valid json format. + try: + payload = json.loads(msg.payload) + except ValueError as err: + raise PGLabDiscoveryError( + f"Can't decode discovery payload: {msg.payload!r}" + ) from err + + device_id = "id" + + # Check if the key id is present in the payload. It must always be present. + if device_id not in payload: + raise PGLabDiscoveryError( + "Unexpected discovery payload format, id key not present" + ) + + # Do a sanity check: the id must match the discovery topic /pglab/discovery/[id]/config + topic = msg.topic + if not topic.endswith(f"{payload[device_id]}/config"): + raise PGLabDiscoveryError("Unexpected discovery topic format") + + # Build and configure the PGLab device. + pglab_device = PyPGLabDevice() + if not await pglab_device.config(mqtt, payload): + raise PGLabDiscoveryError("Error during setup of a new discovered device") + + return pglab_device + + def __clean_discovered_device(self, hass: HomeAssistant, device_id: str) -> None: + """Destroy the device and any entities connected to the device.""" + + if device_id not in self._discovered: + return + + discovery_info = self._discovered[device_id] + + # Destroy all entities connected to the device. + entity_registry = er.async_get(hass) + for platform, unique_id in discovery_info.entities: + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + + # Destroy the device. + device_registry = dr.async_get(hass) + if device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ): + device_registry.async_remove_device(device_entry.id) + + # Clean the discovery info. + del self._discovered[device_id] + + async def start( + self, hass: HomeAssistant, mqtt: PyPGLabMqttClient, entry: PGLABConfigEntry + ) -> None: + """Start discovering a PGLab devices.""" + + async def discovery_message_received(msg: ReceiveMessage) -> None: + """Received a new discovery message.""" + + # Create a PGLab device and add entities. + try: + pglab_device = await self.__build_device(mqtt, msg) + except PGLabDiscoveryError as err: + LOGGER.warning("Can't create PGLabDiscovery instance(%s) ", str(err)) + + # For some reason it's not possible to create the device with the discovery message, + # be sure that any previous device with the same topic is now destroyed. + device_id = get_device_id_from_discovery_topic(msg.topic) + + # If there is a valid topic device_id clean everything relative to the device. + if device_id: + self.__clean_discovered_device(hass, device_id) + + return + + # Create a new device. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + configuration_url=f"http://{pglab_device.ip}/", + connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, + identifiers={(DOMAIN, pglab_device.id)}, + manufacturer=pglab_device.manufactor, + model=pglab_device.type, + name=pglab_device.name, + sw_version=pglab_device.firmware_version, + hw_version=pglab_device.hardware_version, + ) + + # Do some checking if previous entities must be updated. + if pglab_device.id in self._discovered: + # The device is already been discovered, + # get the old discovery info data. + discovery_info = self._discovered[pglab_device.id] + + if discovery_info.hash == pglab_device.hash: + # Best case, there is nothing to do. + # The device is still in the same configuration. Same name, same shutters, same relay etc. + return + + LOGGER.warning( + "Changed internal configuration of device(%s). Rebuilding all entities", + pglab_device.id, + ) + + # Something has changed, all previous entities must be destroyed and re-created. + self.__clean_discovered_device(hass, pglab_device.id) + + # Add a new device. + discovery_info = await createDiscoverDeviceInfo(pglab_device) + self._discovered[pglab_device.id] = discovery_info + + # Create all new relay entities. + for r in pglab_device.relays: + # The HA entity is not yet created, send a message to create it. + async_dispatcher_send( + hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r + ) + + # Create all new sensor entities. + async_dispatcher_send( + hass, + CREATE_NEW_ENTITY[Platform.SENSOR], + pglab_device, + discovery_info.sensors, + ) + + topics = { + "discovery_topic": { + "topic": f"{self._discovery_topic}/#", + "msg_callback": discovery_message_received, + } + } + + # Forward setup all HA supported platforms. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + self._mqtt_client = mqtt + self._substate = async_prepare_subscribe_topics(hass, self._substate, topics) + await async_subscribe_topics(hass, self._substate) + + async def register_platform( + self, hass: HomeAssistant, platform: Platform, target: Callable[..., Any] + ): + """Register a callback to create entity of a specific HA platform.""" + disconnect_callback = async_dispatcher_connect( + hass, CREATE_NEW_ENTITY[platform], target + ) + self._disconnect_platform.append(disconnect_callback) + + async def stop(self, hass: HomeAssistant, entry: PGLABConfigEntry) -> None: + """Stop to discovery PG LAB devices.""" + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Disconnect all registered platforms. + for disconnect_callback in self._disconnect_platform: + disconnect_callback() + + async_unsubscribe_topics(hass, self._substate) + + async def add_entity(self, entity: Entity, device_id: str): + """Save a new PG LAB device entity.""" + + # Be sure that the device is been discovered. + if device_id not in self._discovered: + raise PGLabDiscoveryError("Unknown device, device_id not discovered") + + discovery_info = self._discovered[device_id] + discovery_info.add_entity(entity) diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py new file mode 100644 index 00000000000..175b4c1eb0f --- /dev/null +++ b/homeassistant/components/pglab/entity.py @@ -0,0 +1,76 @@ +"""Entity for PG LAB Electronics.""" + +from __future__ import annotations + +from pypglab.device import Device as PyPGLabDevice +from pypglab.entity import Entity as PyPGLabEntity + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .discovery import PGLabDiscovery + + +class PGLabEntity(Entity): + """Representation of a PGLab entity in Home Assistant.""" + + _attr_has_entity_name = True + + def __init__( + self, + discovery: PGLabDiscovery, + device: PyPGLabDevice, + entity: PyPGLabEntity, + ) -> None: + """Initialize the class.""" + + self._id = entity.id + self._device_id = device.id + self._entity = entity + self._discovery = discovery + + # Information about the device that is partially visible in the UI. + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=device.name, + sw_version=device.firmware_version, + hw_version=device.hardware_version, + model=device.type, + manufacturer=device.manufactor, + configuration_url=f"http://{device.ip}/", + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + ) + + async def subscribe_to_update(self): + """Subscribe to the entity updates.""" + self._entity.set_on_state_callback(self.state_updated) + await self._entity.subscribe_topics() + + async def unsubscribe_to_update(self): + """Unsubscribe to the entity updates.""" + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) + + async def async_added_to_hass(self) -> None: + """Update the device discovery info.""" + + await self.subscribe_to_update() + await super().async_added_to_hass() + + # Inform PGLab discovery instance that a new entity is available. + # This is important to know in case the device needs to be reconfigured + # and the entity can be potentially destroyed. + await self._discovery.add_entity(self, self._device_id) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe when removed.""" + + await super().async_will_remove_from_hass() + await self.unsubscribe_to_update() + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + self.async_write_ha_state() diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json new file mode 100644 index 00000000000..7f7d596be77 --- /dev/null +++ b/homeassistant/components/pglab/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "pglab", + "name": "PG LAB Electronics", + "codeowners": ["@pglab-electronics"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/pglab", + "iot_class": "local_push", + "loggers": ["pglab"], + "mqtt": ["pglab/discovery/#"], + "quality_scale": "bronze", + "requirements": ["pypglab==0.0.3"], + "single_config_entry": true +} diff --git a/homeassistant/components/pglab/quality_scale.yaml b/homeassistant/components/pglab/quality_scale.yaml new file mode 100644 index 00000000000..dda637e5833 --- /dev/null +++ b/homeassistant/components/pglab/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: The integration relies solely on auto-discovery. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + docs-installation-parameters: + status: exempt + comment: There are no parameters. + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: The integration has no settings. + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py new file mode 100644 index 00000000000..f868e7ae101 --- /dev/null +++ b/homeassistant/components/pglab/sensor.py @@ -0,0 +1,119 @@ +"""Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import timedelta + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import PGLABConfigEntry +from .device_sensor import PGLabDeviceSensor +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + +SENSOR_INFO: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_VOLTAGE, + translation_key="mpu_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_REBOOT_TIME, + translation_key="runtime", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + ) -> None: + """Discover and add a PG LAB Sensor.""" + pglab_discovery = config_entry.runtime_data + for description in SENSOR_INFO: + pglab_sensor = PGLabSensor( + pglab_discovery, pglab_device, pglab_device_sensor, description + ) + async_add_entities([pglab_sensor]) + + # Register the callback to create the sensor entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) + + +class PGLabSensor(PGLabEntity, SensorEntity): + """A PGLab sensor.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_device_sensor.sensors, + ) + + self._type = description.key + self._pglab_device_sensor = pglab_device_sensor + self._attr_unique_id = f"{pglab_device.id}_{description.key}" + self.entity_description = description + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # get the sensor value from pglab multi fields sensor + value = self._pglab_device_sensor.state[self._type] + + if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + self._attr_native_value = utcnow() - timedelta(seconds=value) + else: + self._attr_native_value = value + + super().state_updated(payload) + + async def subscribe_to_update(self): + """Register the HA sensor to be notify when the sensor status is changed.""" + self._pglab_device_sensor.add_ha_sensor(self) + + async def unsubscribe_to_update(self): + """Unregister the HA sensor from sensor tatus updates.""" + self._pglab_device_sensor.remove_ha_sensor(self) diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json new file mode 100644 index 00000000000..4fad408ad98 --- /dev/null +++ b/homeassistant/components/pglab/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "confirm_from_user": { + "description": "In order to be found PG LAB Electronics devices need to be connected to the same broker as the Home Assistant MQTT integration client. Do you want to continue?" + }, + "confirm_from_mqtt": { + "description": "Do you want to set up PG LAB Electronics?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.", + "mqtt_not_configured": "Home Assistant MQTT integration not configured." + } + }, + "entity": { + "switch": { + "relay": { + "name": "Relay {relay_id}" + } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "runtime": { + "name": "Run time" + }, + "mpu_voltage": { + "name": "MPU voltage" + } + } + } +} diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py new file mode 100644 index 00000000000..554b5cf80ca --- /dev/null +++ b/homeassistant/components/pglab/switch.py @@ -0,0 +1,76 @@ +"""Switch for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.relay import Relay as PyPGLabRelay + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PGLABConfigEntry +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switches for device.""" + + @callback + def async_discover(pglab_device: PyPGLabDevice, pglab_relay: PyPGLabRelay) -> None: + """Discover and add a PGLab Relay.""" + pglab_discovery = config_entry.runtime_data + pglab_switch = PGLabSwitch(pglab_discovery, pglab_device, pglab_relay) + async_add_entities([pglab_switch]) + + # Register the callback to create the switch entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SWITCH, async_discover) + + +class PGLabSwitch(PGLabEntity, SwitchEntity): + """A PGLab switch.""" + + _attr_translation_key = "relay" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_relay: PyPGLabRelay, + ) -> None: + """Initialize the Switch class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_relay, + ) + + self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}" + self._attr_translation_placeholders = {"relay_id": pglab_relay.id} + + self._relay = pglab_relay + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self._relay.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self._relay.turn_off() + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._relay.state diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 93f869e849d..9ff101915b8 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -7,7 +7,6 @@ import logging from haphilipsjs import PhilipsTV from haphilipsjs.typing import SystemType -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, @@ -18,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .const import CONF_SYSTEM -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -30,8 +29,6 @@ PLATFORMS = [ LOGGER = logging.getLogger(__name__) -PhilipsTVConfigEntry = ConfigEntry[PhilipsTVDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> bool: """Set up Philips TV from a config entry.""" @@ -44,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> password=entry.data.get(CONF_PASSWORD), system=system, ) - coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi, entry.options) + coordinator = PhilipsTVDataUpdateCoordinator(hass, entry, tvapi) await coordinator.async_refresh() diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 6de814efd97..3667d37dc48 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -11,10 +11,9 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity @@ -42,7 +41,7 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/coordinator.py b/homeassistant/components/philips_js/coordinator.py index cae59fa5123..f450e971093 100644 --- a/homeassistant/components/philips_js/coordinator.py +++ b/homeassistant/components/philips_js/coordinator.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any from haphilipsjs import ( AutenticationFailure, @@ -27,23 +25,28 @@ from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN _LOGGER = logging.getLogger(__name__) +type PhilipsTVConfigEntry = ConfigEntry[PhilipsTVDataUpdateCoordinator] + class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): """Coordinator to update data.""" - config_entry: ConfigEntry + config_entry: PhilipsTVConfigEntry def __init__( - self, hass: HomeAssistant, api: PhilipsTV, options: Mapping[str, Any] + self, + hass: HomeAssistant, + config_entry: PhilipsTVConfigEntry, + api: PhilipsTV, ) -> None: """Set up the coordinator.""" self.api = api - self.options = options self._notify_future: asyncio.Task | None = None super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), request_refresh_debouncer=Debouncer( @@ -91,7 +94,7 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self.api.on and self.api.powerstate == "On" and self.api.notify_change_supported - and self.options.get(CONF_ALLOW_NOTIFY, False) + and self.config_entry.options.get(CONF_ALLOW_NOTIFY, False) ) async def _notify_task(self): diff --git a/homeassistant/components/philips_js/diagnostics.py b/homeassistant/components/philips_js/diagnostics.py index 625b77f6c25..99b27b2c85a 100644 --- a/homeassistant/components/philips_js/diagnostics.py +++ b/homeassistant/components/philips_js/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import PhilipsTVConfigEntry +from .coordinator import PhilipsTVConfigEntry TO_REDACT = { "serialnumber_encrypted", diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 1d63b2062e6..bf15292335e 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -18,11 +18,10 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv -from . import PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity EFFECT_PARTITION = ": " @@ -35,7 +34,7 @@ EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"} async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index bd8727ae9c1..a433a63f31f 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -18,11 +18,11 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from . import LOGGER as _LOGGER, PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from . import LOGGER as _LOGGER +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger @@ -49,7 +49,7 @@ def _inverted(data): async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index f8d9cb0885d..b026b33a857 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -13,11 +13,11 @@ from homeassistant.components.remote import ( RemoteEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from . import LOGGER, PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from . import LOGGER +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger @@ -25,7 +25,7 @@ from .helpers import async_get_turn_on_trigger async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index b35b2ad4ff1..45963432665 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -6,10 +6,9 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity HUE_POWER_OFF = "Off" @@ -19,7 +18,7 @@ HUE_POWER_ON = "On" async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 5e3ce560ab4..1d12307b6e5 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleConfigEntry @@ -41,7 +41,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole binary sensor.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 4cf5133e700..54a9cb23d02 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -7,7 +7,7 @@ from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -47,7 +47,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole sensor.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 9e1d5948a09..504be7a62dd 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -17,8 +17,8 @@ } }, "reauth_confirm": { - "title": "Reauthenticate PI-Hole", - "description": "Please enter a new api key for PI-Hole at {host}/{location}", + "title": "Reauthenticate Pi-hole", + "description": "Please enter a new API key for Pi-hole at {host}/{location}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index 805ba479a9e..84ffe7e51a4 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME 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.entity_platform import AddConfigEntryEntitiesCallback from . import PiHoleConfigEntry from .const import SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole switch.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index 510f5d1dc19..56e92b47289 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -10,7 +10,7 @@ from hole import Hole from homeassistant.components.update import UpdateEntity, UpdateEntityDescription from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleConfigEntry @@ -65,7 +65,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole update entities.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index d2f023af79f..8de407133cd 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -1,6 +1,6 @@ """The Picnic integration.""" -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 9548029209b..a60086173a8 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -6,8 +6,8 @@ from collections.abc import Mapping import logging from typing import Any -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError import requests import voluptuous as vol @@ -67,8 +67,8 @@ async def validate_input(hass: HomeAssistant, data): # Return the validation result address = ( - f'{user_data["address"]["street"]} {user_data["address"]["house_number"]}' - f'{user_data["address"]["house_number_ext"]}' + f"{user_data['address']['street']} {user_data['address']['house_number']}" + f"{user_data['address']['house_number_ext']}" ) return auth_token, { "title": address, diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index c367d5ec548..9b23157dbf3 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -6,8 +6,8 @@ import copy from datetime import timedelta import logging -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN @@ -21,6 +21,8 @@ from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT class PicnicUpdateCoordinator(DataUpdateCoordinator): """The coordinator to fetch data from the Picnic API at a set interval.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -29,13 +31,13 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): ) -> None: """Initialize the coordinator with the given Picnic API client.""" self.picnic_api_client = picnic_api_client - self.config_entry = config_entry self._user_address = None logger = logging.getLogger(__name__) super().__init__( hass, logger, + config_entry=config_entry, name="Picnic coordinator", update_interval=timedelta(minutes=30), ) @@ -79,7 +81,10 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): """Get the address that identifies the Picnic service.""" if self._user_address is None: address = self.picnic_api_client.get_user()["address"] - self._user_address = f'{address["street"]} {address["house_number"]}{address["house_number_ext"]}' + self._user_address = ( + f"{address['street']} " + f"{address['house_number']}{address['house_number_ext']}" + ) return self._user_address diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 947dd0241d2..09f28da39a4 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -1,10 +1,10 @@ { "domain": "picnic", "name": "Picnic", - "codeowners": ["@corneyl"], + "codeowners": ["@corneyl", "@codesalatdev"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", - "loggers": ["python_picnic_api"], - "requirements": ["python-picnic-api==1.1.0"] + "loggers": ["python_picnic_api2"], + "requirements": ["python-picnic-api2==1.2.2"] } diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 866bd6b56c1..dcfd9086491 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -203,7 +203,7 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Picnic sensor entries.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index c01fc00a29e..76d7b8a6c44 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -4,11 +4,11 @@ from __future__ import annotations from typing import cast -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 7fa2bbccd3e..383c236de3c 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COORDINATOR, DOMAIN @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Picnic shopping cart todo platform config entry.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 21d5603e4c2..5f1238772b0 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index d2d83813516..fbfa5cfb5e1 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -10,7 +10,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, EVENT, SERVICE_NAME @@ -86,7 +86,7 @@ class PilightBaseDevice(RestoreEntity): self._brightness = 255 - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): @@ -99,7 +99,7 @@ class PilightBaseDevice(RestoreEntity): return self._name @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index c3d1a3c234c..9e1ecbf59d4 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_LIGHTS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 5ab80f57dc6..532681e2b93 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py index a1976921269..9b812075e17 100644 --- a/homeassistant/components/pilight/switch.py +++ b/homeassistant/components/pilight/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_SWITCHES from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 4b03e5e4407..14203541359 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -6,7 +6,6 @@ import logging from icmplib import SocketPermissionError, async_ping -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -14,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import CONF_PING_COUNT, DOMAIN -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) @@ -24,9 +23,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] DATA_PRIVILEGED_KEY: HassKey[bool | None] = HassKey(DOMAIN) -type PingConfigEntry = ConfigEntry[PingUpdateCoordinator] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" hass.data[DATA_PRIVILEGED_KEY] = await _can_use_icmp_lib_with_privilege() @@ -47,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool ping_cls = PingDataICMPLib coordinator = PingUpdateCoordinator( - hass=hass, ping=ping_cls(hass, host, count, privileged) + hass=hass, config_entry=entry, ping=ping_cls(hass, host, count, privileged) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 5c50e4335f9..35bf2707694 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -6,18 +6,18 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PingConfigEntry from .const import CONF_IMPORTED_BY -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator from .entity import PingEntity async def async_setup_entry( - hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Ping config entry.""" async_add_entities([PingBinarySensor(entry, entry.runtime_data)]) @@ -31,7 +31,7 @@ class PingBinarySensor(PingEntity, BinarySensorEntity): _attr_name = None def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + self, config_entry: PingConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" super().__init__(config_entry, coordinator, config_entry.entry_id) diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index 38ab2e79ffc..afb7de4dce3 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -7,6 +7,7 @@ from datetime import timedelta import logging from typing import Any +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,6 +15,8 @@ from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) +type PingConfigEntry = ConfigEntry[PingUpdateCoordinator] + @dataclass(slots=True, frozen=True) class PingResult: @@ -27,11 +30,13 @@ class PingResult: class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): """The Ping update coordinator.""" + config_entry: PingConfigEntry ping: PingDataSubProcess | PingDataICMPLib def __init__( self, hass: HomeAssistant, + config_entry: PingConfigEntry, ping: PingDataSubProcess | PingDataICMPLib, ) -> None: """Initialize the Ping coordinator.""" @@ -40,6 +45,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Ping {ping.ip_address}", update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 29a4e922234..9d093da262d 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -9,19 +9,19 @@ from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, ScannerEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import PingConfigEntry from .const import CONF_IMPORTED_BY -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Ping config entry.""" async_add_entities([PingDeviceTracker(entry, entry.runtime_data)]) @@ -33,7 +33,7 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) _last_seen: datetime | None = None def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + self, config_entry: PingConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping device tracker.""" super().__init__(coordinator) diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py index a1f84f6ef32..d592ef6b549 100644 --- a/homeassistant/components/ping/entity.py +++ b/homeassistant/components/ping/entity.py @@ -1,11 +1,10 @@ """Base entity for the Ping component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator class PingEntity(CoordinatorEntity[PingUpdateCoordinator]): @@ -15,7 +14,7 @@ class PingEntity(CoordinatorEntity[PingUpdateCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: PingConfigEntry, coordinator: PingUpdateCoordinator, unique_id: str, ) -> None: diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index 82ebf4532da..996faa99c5b 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -160,7 +160,7 @@ class PingDataSubProcess(PingData): ) if pinger: - with suppress(TypeError): + with suppress(TypeError, ProcessLookupError): await pinger.kill() # type: ignore[func-returns-value] del pinger diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py index 6e6c4cf2cde..82d88064e02 100644 --- a/homeassistant/components/ping/sensor.py +++ b/homeassistant/components/ping/sensor.py @@ -12,10 +12,9 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PingConfigEntry -from .coordinator import PingResult, PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingResult, PingUpdateCoordinator from .entity import PingEntity @@ -76,7 +75,9 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ping sensors from config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 670ccffaea7..385acbe4818 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -3,9 +3,9 @@ from __future__ import annotations import logging -import telnetlib # pylint: disable=deprecated-module from typing import Final +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.media_player import ( @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 93f8ea5ad9b..1e035205f8f 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 585b6ecfd82..14e757d4623 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -121,7 +121,9 @@ async def async_setup_coordinator(hass: HomeAssistant, entry: ConfigEntry): else: update_interval = timedelta(minutes=DEFAULT_SCAN_INTERVAL) - coordinator = PlaatoCoordinator(hass, auth_token, device_type, update_interval) + coordinator = PlaatoCoordinator( + hass, entry, auth_token, device_type, update_interval + ) await coordinator.async_config_entry_first_refresh() _set_entry_data(entry, hass, coordinator, auth_token) diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index 42019bbec9b..b71673aa1fd 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_USE_WEBHOOK, COORDINATOR, DOMAIN from .entity import PlaatoEntity @@ -19,7 +19,7 @@ from .entity import PlaatoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plaato from a config entry.""" diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index f398a733cd6..9adfb4a14fe 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CLOUDHOOK, diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py index 8d21f17880a..df360d50068 100644 --- a/homeassistant/components/plaato/coordinator.py +++ b/homeassistant/components/plaato/coordinator.py @@ -5,6 +5,7 @@ import logging from pyplaato.plaato import Plaato, PlaatoDeviceType +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -18,9 +19,12 @@ _LOGGER = logging.getLogger(__name__) class PlaatoCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, auth_token: str, device_type: PlaatoDeviceType, update_interval: timedelta, @@ -34,6 +38,7 @@ class PlaatoCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=update_interval, ) diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 7ab8367bd1d..9cc63a38a64 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -73,13 +73,13 @@ class PlaatoEntity(entity.Entity): return None @property - def available(self): + def available(self) -> bool: """Return if sensor is available.""" if self._coordinator is not None: return self._coordinator.last_update_success return True - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" if self._coordinator is not None: self.async_on_remove( diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index b11bac40144..7a98c8a1ced 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -12,7 +12,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_TEMP, SENSOR_UPDATE @@ -38,7 +41,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plaato from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 48c606865df..27993a93779 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -32,7 +32,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 8bb34be38ce..5ed34eac6b2 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -8,7 +8,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PlexServer from .const import CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL @@ -18,7 +18,7 @@ from .helpers import get_plex_server async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex button from config entry.""" server_id: str = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index ae7cbb12574..3c9f35b20a4 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( AUTH_CALLBACK_NAME, diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 1dd79ad27a5..4a1654959f6 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.network import is_internal_request from .const import ( @@ -68,7 +68,7 @@ def needs_session[_PlexMediaPlayerT: PlexMediaPlayer, **_P, _R]( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex media_player from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index eb27f465a7e..66e513dd83a 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_SERVER_IDENTIFIER, @@ -52,7 +52,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex sensor from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index eab1d086d4c..7f9c2545032 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -203,7 +203,7 @@ class PlexServer: config_entry_update_needed = True else: # pylint: disable-next=raise-missing-from - raise Unauthorized( # noqa: TRY200 + raise Unauthorized( # noqa: B904 "New certificate cannot be validated" " with provided token" ) diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py index 7acf4551f33..9b7645cd078 100644 --- a/homeassistant/components/plex/update.py +++ b/homeassistant/components/plex/update.py @@ -11,7 +11,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_SERVER_IDENTIFIER from .helpers import get_plex_server @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex update entities from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index a100103b029..e97493a78a7 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -4,22 +4,19 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN, LOGGER, PLATFORMS -from .coordinator import PlugwiseDataUpdateCoordinator - -type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator] +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> bool: """Set up Plugwise components from a config entry.""" await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) - coordinator = PlugwiseDataUpdateCoordinator(hass) + coordinator = PlugwiseDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() migrate_sensor_entities(hass, coordinator) @@ -82,7 +79,7 @@ def migrate_sensor_entities( # Migrating opentherm_outdoor_temperature # to opentherm_outdoor_air_temperature sensor - for device_id, device in coordinator.data.devices.items(): + for device_id, device in coordinator.data.items(): if device["dev_class"] != "heater_central": continue diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 539fa243d6c..f2c2fd6ed68 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -15,10 +15,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity SEVERITIES = ["other", "info", "warning", "error"] @@ -86,7 +85,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile binary_sensors from a config entry.""" coordinator = entry.runtime_data @@ -100,11 +99,7 @@ async def async_setup_entry( async_add_entities( PlugwiseBinarySensorEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if ( - binary_sensors := coordinator.data.devices[device_id].get( - "binary_sensors" - ) - ) + if (binary_sensors := coordinator.data[device_id].get("binary_sensors")) for description in BINARY_SENSORS if description.key in binary_sensors ) @@ -141,7 +136,8 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): return None attrs: dict[str, list[str]] = {f"{severity}_msg": [] for severity in SEVERITIES} - if notify := self.coordinator.data.gateway["notifications"]: + gateway_id = self.coordinator.api.gateway_id + if notify := self.coordinator.data[gateway_id]["notifications"]: for details in notify.values(): for msg_type, msg in details.items(): msg_type = msg_type.lower() diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index 8a05ede3496..c0896b602f0 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -5,11 +5,10 @@ from __future__ import annotations from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry -from .const import GATEWAY_ID, REBOOT -from .coordinator import PlugwiseDataUpdateCoordinator +from .const import REBOOT +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -19,16 +18,15 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Plugwise buttons from a ConfigEntry.""" coordinator = entry.runtime_data - gateway = coordinator.data.gateway async_add_entities( PlugwiseButtonEntity(coordinator, device_id) - for device_id in coordinator.data.devices - if device_id == gateway[GATEWAY_ID] and REBOOT in gateway + for device_id in coordinator.data + if device_id == coordinator.api.gateway_id and coordinator.api.reboot ) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 3caed1e7bc2..c7fac07f1cb 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -16,11 +16,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry from .const import DOMAIN, MASTER_THERMOSTATS -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -30,7 +29,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile Thermostats from a config entry.""" coordinator = entry.runtime_data @@ -41,18 +40,17 @@ async def async_setup_entry( if not coordinator.new_devices: return - if coordinator.data.gateway["smile_name"] == "Adam": + if coordinator.api.smile_name == "Adam": async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] == "climate" + if coordinator.data[device_id]["dev_class"] == "climate" ) else: async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] - in MASTER_THERMOSTATS + if coordinator.data[device_id]["dev_class"] in MASTER_THERMOSTATS ) _add_entities() @@ -77,10 +75,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_unique_id = f"{device_id}-climate" - self._devices = coordinator.data.devices - self._gateway = coordinator.data.gateway - gateway_id: str = self._gateway["gateway_id"] - self._gateway_data = self._devices[gateway_id] + gateway_id: str = coordinator.api.gateway_id + self._gateway_data = coordinator.data[gateway_id] self._location = device_id if (location := self.device.get("location")) is not None: @@ -88,7 +84,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam": + if ( + self.coordinator.api.cooling_present + and coordinator.api.smile_name != "Adam" + ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -170,7 +169,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if "available_schedules" in self.device: hvac_modes.append(HVACMode.AUTO) - if self._gateway["cooling_present"]: + if self.coordinator.api.cooling_present: if "regulation_modes" in self._gateway_data: if self._gateway_data["select_regulation_mode"] == "cooling": hvac_modes.append(HVACMode.COOL) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 6114dd39a6d..bf33d4c4a0f 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -16,7 +16,6 @@ from plugwise.exceptions import ( ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import ( ATTR_CONFIGURATION_URL, @@ -29,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( DEFAULT_PORT, @@ -59,8 +59,6 @@ def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: schema = schema.extend( { vol.Required(CONF_HOST): str, - # Port under investigation for removal (hence not added in #132878) - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_USERNAME, default=SMILE): vol.In( {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} ), @@ -105,7 +103,7 @@ async def verify_connection( errors[CONF_BASE] = "response_error" except UnsupportedDeviceError: errors[CONF_BASE] = "unsupported" - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Unknown exception while verifying connection with your Plugwise Smile" ) @@ -197,6 +195,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + user_input[CONF_PORT] = DEFAULT_PORT if self.discovery_info: user_input[CONF_HOST] = self.discovery_info.host user_input[CONF_PORT] = self.discovery_info.port diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 5e4dea5586b..176ae39b1ba 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -17,7 +17,6 @@ FLOW_SMILE: Final = "smile (Adam/Anna/P1)" FLOW_STRETCH: Final = "stretch (Stretch)" FLOW_TYPE: Final = "flow_type" GATEWAY: Final = "gateway" -GATEWAY_ID: Final = "gateway_id" LOCATION: Final = "location" PW_TYPE: Final = "plugwise_type" REBOOT: Final = "reboot" diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 7ac0cc21c51..b346f26492c 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta from packaging.version import Version -from plugwise import PlugwiseData, Smile +from plugwise import GwEntityData, Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -22,21 +22,24 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, GATEWAY_ID, LOGGER +from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, LOGGER + +type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator] -class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): +class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]): """Class to manage fetching Plugwise data from single endpoint.""" _connected: bool = False - config_entry: ConfigEntry + config_entry: PlugwiseConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), # Don't refresh immediately, give the device time to process @@ -63,10 +66,8 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): """Connect to the Plugwise Smile.""" version = await self.api.connect() self._connected = isinstance(version, Version) - if self._connected: - self.api.get_all_gateway_entities() - async def _async_update_data(self) -> PlugwiseData: + async def _async_update_data(self) -> dict[str, GwEntityData]: """Fetch data from Plugwise.""" try: if not self._connected: @@ -101,26 +102,28 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): self._async_add_remove_devices(data, self.config_entry) return data - def _async_add_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: + def _async_add_remove_devices( + self, data: dict[str, GwEntityData], entry: ConfigEntry + ) -> None: """Add new Plugwise devices, remove non-existing devices.""" # Check for new or removed devices - self.new_devices = set(data.devices) - self._current_devices - removed_devices = self._current_devices - set(data.devices) - self._current_devices = set(data.devices) + self.new_devices = set(data) - self._current_devices + removed_devices = self._current_devices - set(data) + self._current_devices = set(data) if removed_devices: self._async_remove_devices(data, entry) - def _async_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: + def _async_remove_devices( + self, data: dict[str, GwEntityData], entry: ConfigEntry + ) -> None: """Clean registries when removed devices found.""" device_reg = dr.async_get(self.hass) device_list = dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ) # First find the Plugwise via_device - gateway_device = device_reg.async_get_device( - {(DOMAIN, data.gateway[GATEWAY_ID])} - ) + gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)}) assert gateway_device is not None via_device_id = gateway_device.id @@ -130,7 +133,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): if identifier[0] == DOMAIN: if ( device_entry.via_device_id == via_device_id - and identifier[1] not in data.devices + and identifier[1] not in data ): device_reg.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index 47ff7d1a9fb..e97405f6279 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PlugwiseConfigEntry +from .coordinator import PlugwiseConfigEntry async def async_get_config_entry_diagnostics( @@ -14,7 +14,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data - return { - "devices": coordinator.data.devices, - "gateway": coordinator.data.gateway, - } + return coordinator.data diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 3f63abaff43..39838c38fde 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -34,7 +34,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): if entry := self.coordinator.config_entry: configuration_url = f"http://{entry.data[CONF_HOST]}" - data = coordinator.data.devices[device_id] + data = coordinator.data[device_id] connections = set() if mac := data.get("mac_address"): connections.add((CONNECTION_NETWORK_MAC, mac)) @@ -48,18 +48,18 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): manufacturer=data.get("vendor"), model=data.get("model"), model_id=data.get("model_id"), - name=coordinator.data.gateway["smile_name"], + name=coordinator.api.smile_name, sw_version=data.get("firmware"), hw_version=data.get("hardware"), ) - if device_id != coordinator.data.gateway["gateway_id"]: + if device_id != coordinator.api.gateway_id: self._attr_device_info.update( { ATTR_NAME: data.get("name"), ATTR_VIA_DEVICE: ( DOMAIN, - str(self.coordinator.data.gateway["gateway_id"]), + str(self.coordinator.api.gateway_id), ), } ) @@ -68,7 +68,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): def available(self) -> bool: """Return if entity is available.""" return ( - self._dev_id in self.coordinator.data.devices + self._dev_id in self.coordinator.data and ("available" not in self.device or self.device["available"] is True) and super().available ) @@ -76,4 +76,4 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): @property def device(self) -> GwEntityData: """Return data for this device.""" - return self.coordinator.data.devices[self._dev_id] + return self.coordinator.data[self._dev_id] diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ae60d4d7452..87878980f2d 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.6.4"], + "quality_scale": "platinum", + "requirements": ["plugwise==1.7.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 1d0b1382c24..1dbb0506748 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -12,11 +12,10 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry from .const import NumberType -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -58,7 +57,7 @@ NUMBER_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plugwise number platform.""" coordinator = entry.runtime_data @@ -73,7 +72,7 @@ async def async_setup_entry( PlugwiseNumberEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in NUMBER_TYPES - if description.key in coordinator.data.devices[device_id] + if description.key in coordinator.data[device_id] ) _add_entities() diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml index a7b955b4713..55abf3c330e 100644 --- a/homeassistant/components/plugwise/quality_scale.yaml +++ b/homeassistant/components/plugwise/quality_scale.yaml @@ -15,12 +15,8 @@ rules: status: exempt comment: Plugwise integration has no custom actions common-modules: done - docs-high-level-description: - status: todo - comment: Rewrite top section, docs PR prepared waiting for 36087 merge - docs-installation-instructions: - status: todo - comment: Docs PR 36087 + docs-high-level-description: done + docs-installation-instructions: done docs-removal-instructions: done docs-actions: done brands: done @@ -35,9 +31,7 @@ rules: parallel-updates: done test-coverage: done integration-owner: done - docs-installation-parameters: - status: todo - comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: Plugwise has no options flow @@ -58,25 +52,13 @@ rules: repair-issues: status: exempt comment: This integration does not have repairs - docs-use-cases: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-supported-devices: - status: todo - comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge - docs-supported-functions: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done docs-data-update: done - docs-known-limitations: - status: todo - comment: Partial in 36087 but could be more elaborate - docs-troubleshooting: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-examples: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done ## Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index ff268d8eded..6ca1d4ce7a2 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -7,11 +7,10 @@ from dataclasses import dataclass from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry from .const import SelectOptionsType, SelectType -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -56,7 +55,7 @@ SELECT_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile selector from a config entry.""" coordinator = entry.runtime_data @@ -71,7 +70,7 @@ async def async_setup_entry( PlugwiseSelectEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in SELECT_TYPES - if description.options_key in coordinator.data.devices[device_id] + if description.options_key in coordinator.data[device_id] ) _add_entities() diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 14b42682376..7bd93e2ff84 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -25,10 +25,9 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity # Coordinator is used to centralize the data updates @@ -406,7 +405,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile sensors from a config entry.""" coordinator = entry.runtime_data @@ -420,7 +419,7 @@ async def async_setup_entry( async_add_entities( PlugwiseSensorEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if (sensors := coordinator.data.devices[device_id].get("sensors")) + if (sensors := coordinator.data[device_id].get("sensors")) for description in SENSORS if description.key in sensors ) diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index ea6d6f18b7f..8179fb546b4 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -14,10 +14,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PlugwiseConfigEntry -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -58,7 +57,7 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile switches from a config entry.""" coordinator = entry.runtime_data @@ -72,7 +71,7 @@ async def async_setup_entry( async_add_entities( PlugwiseSwitchEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if (switches := coordinator.data.devices[device_id].get("switches")) + if (switches := coordinator.data[device_id].get("switches")) for description in SWITCHES if description.key in switches ) diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index a385565b837..78743c12808 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -16,8 +16,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .const import DOMAIN @@ -25,7 +25,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plum Lightpad dimmer lights and glow rings.""" diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 1f6af298688..bbe75ae544c 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 4e4e4238176..0f501d2ee09 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MinutPointClient from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK @@ -33,7 +33,7 @@ EVENT_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's alarm_control_panel based on a config entry.""" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 546c7d9cb0f..c9338cb63f2 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK from .entity import MinutPointEntity @@ -43,7 +43,7 @@ DEVICES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's binary sensors based on a config entry.""" diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 4784dd43180..5c52e81e6f7 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -52,7 +52,7 @@ class MinutPointEntity(Entity): ) await self._update_callback() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" if self._async_unsub_dispatcher_connect: self._async_unsub_dispatcher_connect() @@ -61,7 +61,7 @@ class MinutPointEntity(Entity): """Update the value of the sensor.""" @property - def available(self): + def available(self) -> bool: """Return true if device is not offline.""" return self._client.is_available(self.device_id) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index d864c8bb18c..c959d09d606 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW @@ -48,7 +48,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's sensors based on a config entry.""" diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index a4b6f7b60d8..a2e54712566 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -4,14 +4,11 @@ import logging from poolsense import PoolSense -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .coordinator import PoolSenseDataUpdateCoordinator - -type PoolSenseConfigEntry = ConfigEntry[PoolSenseDataUpdateCoordinator] +from .coordinator import PoolSenseConfigEntry, PoolSenseDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -33,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PoolSenseConfigEntry) -> _LOGGER.error("Invalid authentication") return False - coordinator = PoolSenseDataUpdateCoordinator(hass, poolsense) + coordinator = PoolSenseDataUpdateCoordinator(hass, entry, poolsense) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index 7668845f318..b93f017501d 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -8,9 +8,9 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PoolSenseConfigEntry +from .coordinator import PoolSenseConfigEntry from .entity import PoolSenseEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -30,7 +30,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: PoolSenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py index d9e7e8468ff..557686f9145 100644 --- a/homeassistant/components/poolsense/coordinator.py +++ b/homeassistant/components/poolsense/coordinator.py @@ -5,11 +5,11 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging -from typing import TYPE_CHECKING from poolsense import PoolSense from poolsense.exceptions import PoolSenseError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import StateType @@ -17,20 +17,30 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN -if TYPE_CHECKING: - from . import PoolSenseConfigEntry - _LOGGER = logging.getLogger(__name__) +type PoolSenseConfigEntry = ConfigEntry[PoolSenseDataUpdateCoordinator] + class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, StateType]]): """Define an object to hold PoolSense data.""" config_entry: PoolSenseConfigEntry - def __init__(self, hass: HomeAssistant, poolsense: PoolSense) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: PoolSenseConfigEntry, + poolsense: PoolSense, + ) -> None: """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(hours=1), + ) self.poolsense = poolsense self.email = self.config_entry.data[CONF_EMAIL] diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index 8cfb982d33b..b0ac4404237 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -9,10 +9,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import PoolSenseConfigEntry +from .coordinator import PoolSenseConfigEntry from .entity import PoolSenseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -65,7 +65,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: PoolSenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py index 243f3aacc4f..8e51985211d 100644 --- a/homeassistant/components/powerfox/__init__.py +++ b/homeassistant/components/powerfox/__init__.py @@ -6,18 +6,15 @@ import asyncio from powerfox import Powerfox, PowerfoxConnectionError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import PowerfoxDataUpdateCoordinator +from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool: """Set up Powerfox from a config entry.""" @@ -34,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> raise ConfigEntryNotReady from err coordinators: list[PowerfoxDataUpdateCoordinator] = [ - PowerfoxDataUpdateCoordinator(hass, client, device) for device in devices + PowerfoxDataUpdateCoordinator(hass, entry, client, device) for device in devices ] await asyncio.gather( diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index a4a26759b69..bd76b7cc166 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -18,15 +18,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]] + class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): """Class to manage fetching Powerfox data from the API.""" - config_entry: ConfigEntry + config_entry: PowerfoxConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: PowerfoxConfigEntry, client: Powerfox, device: Device, ) -> None: @@ -34,6 +37,7 @@ class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py index 4c6b0f8c6eb..8514e42537e 100644 --- a/homeassistant/components/powerfox/diagnostics.py +++ b/homeassistant/components/powerfox/diagnostics.py @@ -9,7 +9,7 @@ from powerfox import HeatMeter, PowerMeter, WaterMeter from homeassistant.core import HomeAssistant -from . import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator +from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index bb72d73b5a8..3938eb01a1b 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.2.0"], + "requirements": ["powerfox==1.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py index 6505139fcd9..ab60c99a58b 100644 --- a/homeassistant/components/powerfox/sensor.py +++ b/homeassistant/components/powerfox/sensor.py @@ -15,10 +15,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PowerfoxConfigEntry -from .coordinator import PowerfoxDataUpdateCoordinator +from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator from .entity import PowerfoxEntity @@ -131,7 +130,7 @@ SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PowerfoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Powerfox sensors based on a config entry.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 6a2522ac43b..d84452c0443 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -14,6 +14,7 @@ from tesla_powerwall import ( Powerwall, PowerwallUnreachableError, ) +from yarl import URL from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.network import is_ip_address -from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL +from .const import ( + AUTH_COOKIE_KEY, + CONFIG_ENTRY_COOKIE, + DOMAIN, + POWERWALL_API_CHANGED, + POWERWALL_COORDINATOR, + UPDATE_INTERVAL, +) from .models import ( PowerwallBaseInfo, PowerwallConfigEntry, @@ -52,6 +60,8 @@ class PowerwallDataManager: self, hass: HomeAssistant, power_wall: Powerwall, + cookie_jar: CookieJar, + entry: PowerwallConfigEntry, ip_address: str, password: str | None, runtime_data: PowerwallRuntimeData, @@ -62,6 +72,8 @@ class PowerwallDataManager: self.password = password self.runtime_data = runtime_data self.power_wall = power_wall + self.cookie_jar = cookie_jar + self.entry = entry @property def api_changed(self) -> int: @@ -72,7 +84,9 @@ class PowerwallDataManager: """Recreate the login on auth failure.""" if self.power_wall.is_authenticated(): await self.power_wall.logout() + # Always use the password when recreating the login await self.power_wall.login(self.password or "") + self.save_auth_cookie() async def async_update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" @@ -116,41 +130,74 @@ class PowerwallDataManager: return data raise RuntimeError("unreachable") + @callback + def save_auth_cookie(self) -> None: + """Save the auth cookie.""" + for cookie in self.cookie_jar: + if cookie.key == AUTH_COOKIE_KEY: + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONFIG_ENTRY_COOKIE: cookie.value}, + ) + _LOGGER.debug("Saved auth cookie") + break + async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" ip_address: str = entry.data[CONF_IP_ADDRESS] password: str | None = entry.data.get(CONF_PASSWORD) + + cookie_jar: CookieJar = CookieJar(unsafe=True) + use_auth_cookie: bool = False + # Try to reuse the auth cookie + auth_cookie_value: str | None = entry.data.get(CONFIG_ENTRY_COOKIE) + if auth_cookie_value: + cookie_jar.update_cookies( + {AUTH_COOKIE_KEY: auth_cookie_value}, + URL(f"http://{ip_address}"), + ) + _LOGGER.debug("Using existing auth cookie") + use_auth_cookie = True + http_session = async_create_clientsession( - hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=cookie_jar ) async with AsyncExitStack() as stack: power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False) stack.push_async_callback(power_wall.close) - try: - base_info = await _login_and_fetch_base_info( - power_wall, ip_address, password - ) + for tries in range(2): + try: + base_info = await _login_and_fetch_base_info( + power_wall, ip_address, password, use_auth_cookie + ) - # Cancel closing power_wall on success - stack.pop_all() - except (TimeoutError, PowerwallUnreachableError) as err: - raise ConfigEntryNotReady from err - except MissingAttributeError as err: - # The error might include some important information about what exactly changed. - _LOGGER.error("The powerwall api has changed: %s", str(err)) - persistent_notification.async_create( - hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE - ) - return False - except AccessDeniedError as err: - _LOGGER.debug("Authentication failed", exc_info=err) - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise ConfigEntryNotReady from err + # Cancel closing power_wall on success + stack.pop_all() + break + except (TimeoutError, PowerwallUnreachableError) as err: + raise ConfigEntryNotReady from err + except MissingAttributeError as err: + # The error might include some important information about what exactly changed. + _LOGGER.error("The powerwall api has changed: %s", str(err)) + persistent_notification.async_create( + hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE + ) + return False + except AccessDeniedError as err: + if use_auth_cookie and tries == 0: + _LOGGER.debug( + "Authentication failed with cookie, retrying with password" + ) + use_auth_cookie = False + continue + _LOGGER.debug("Authentication failed", exc_info=err) + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din if entry.unique_id is not None and is_ip_address(entry.unique_id): @@ -163,7 +210,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> api_instance=power_wall, ) - manager = PowerwallDataManager(hass, power_wall, ip_address, password, runtime_data) + manager = PowerwallDataManager( + hass, + power_wall, + cookie_jar, + entry, + ip_address, + password, + runtime_data, + ) + manager.save_auth_cookie() coordinator = DataUpdateCoordinator( hass, @@ -213,10 +269,11 @@ async def async_migrate_entity_unique_ids( async def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str | None + power_wall: Powerwall, host: str, password: str | None, use_auth_cookie: bool ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" - if password is not None: + # Login step is skipped if password is None or if we are using the auth cookie + if not (password is None or use_auth_cookie): await power_wall.login(password) return await _call_base_info(power_wall, host) diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index c50876e22fb..100e31b1c21 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import PowerWallEntity from .models import PowerwallConfigEntry @@ -23,7 +23,7 @@ CONNECTED_GRID_STATUSES = { async def async_setup_entry( hass: HomeAssistant, entry: PowerwallConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the powerwall sensors.""" powerwall_data = entry.runtime_data diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 0c39392ca19..b082016e562 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -17,7 +17,6 @@ from tesla_powerwall import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -28,10 +27,11 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful -from .const import DOMAIN +from .const import CONFIG_ENTRY_COOKIE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -116,7 +116,7 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): ) and not await _powerwall_is_reachable(ip_address, password) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip @@ -257,8 +257,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): {CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input} ) if not errors: + # We have a new valid connection, old cookie is no longer valid + user_input[CONFIG_ENTRY_COOKIE] = None return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input + reauth_entry, data_updates={**user_input, CONFIG_ENTRY_COOKIE: None} ) self.context["title_placeholders"] = { diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index bb3a6c2355e..186a1221a87 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -18,3 +18,6 @@ ATTR_IS_ACTIVE = "is_active" MODEL = "PowerWall 2" MANUFACTURER = "Tesla" + +CONFIG_ENTRY_COOKIE = "cookie" +AUTH_COOKIE_KEY = "AuthCookie" diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 28506e2a60c..b4988133727 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import POWERWALL_COORDINATOR from .entity import BatteryEntity, PowerWallEntity @@ -213,7 +213,7 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: PowerwallConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the powerwall sensors.""" powerwall_data = entry.runtime_data diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 214ca01fb63..a874161de5b 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import PowerWallEntity from .models import PowerwallConfigEntry, PowerwallRuntimeData @@ -22,7 +22,7 @@ OFF_GRID_STATUSES = { async def async_setup_entry( hass: HomeAssistant, entry: PowerwallConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Powerwall switch platform from Powerwall resources.""" async_add_entities([PowerwallOffGridEnabledEntity(entry.runtime_data)]) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index c7311e8691b..90340bc70fa 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -20,8 +20,7 @@ CONF_IRK = "irk" def _parse_irk(irk: str) -> bytes | None: - if irk.startswith("irk:"): - irk = irk[4:] + irk = irk.removeprefix("irk:") if irk.endswith("="): try: diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py index fbaf0d44751..eaccbd6c785 100644 --- a/homeassistant/components/private_ble_device/device_tracker.py +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.components.device_tracker.config_entry import BaseTrackerEnti from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BasePrivateDeviceEntity @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Device Tracker entities for a config entry.""" async_add_entities([BasePrivateDeviceTracker(config_entry)]) diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 6759cdda0f0..445affbcd57 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.20.0"] + "requirements": ["bluetooth-data-tools==1.23.4"] } diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index e2c4fb0c7da..d8c09500332 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BasePrivateDeviceEntity @@ -92,7 +92,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for Private BLE component.""" async_add_entities( diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 9b2b9736574..04dc6d76a5e 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index 1bf23befbdb..4d090f4d0c1 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ProgettiHWSW Automation from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( - f'{entry.data["host"]}:{entry.data["port"]}' + f"{entry.data['host']}:{entry.data['port']}" ) # Check board validation again to load new values to API. diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index a89b8b3c3f1..40296dcac90 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -9,7 +9,7 @@ from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(DOMAIN) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensors from a config entry.""" board_api = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2202678da9b..2e5ea221dca 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -19,7 +19,7 @@ DATA_SCHEMA = vol.Schema( async def validate_input(hass: HomeAssistant, data): """Validate the user host input.""" - api_instance = ProgettiHWSWAPI(f'{data["host"]}:{data["port"]}') + api_instance = ProgettiHWSWAPI(f"{data['host']}:{data['port']}") is_valid = await api_instance.check_board() if not is_valid: diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 983a2383e99..256d90ae5b7 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -10,7 +10,7 @@ from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(DOMAIN) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches from a config entry.""" board_api = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index be7d394993a..03f53dec390 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index ab012847bba..3adc33e9935 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -63,8 +63,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State -from homeassistant.helpers import entityfilter, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + entityfilter, + state as state_helper, +) from homeassistant.helpers.entity_registry import ( EVENT_ENTITY_REGISTRY_UPDATED, EventEntityRegistryUpdatedData, diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 1c58b64cf55..1f0f89c5f04 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -30,7 +30,9 @@ STATE_MAPPING = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur alarm control panel platform.""" async_add_entities( diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 2df6ff62038..3e1c91713e1 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur camera platform.""" diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index adf5e985fe9..2e649ebd5bd 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.9"] + "requirements": ["pyprosegur==0.0.14"] } diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 1118e747275..e9d2bbde4e5 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -17,8 +17,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 763274243c5..2338464558d 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import ( @@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> """Set up Proximity from a config entry.""" _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) - coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) + coordinator = ProximityDataUpdateCoordinator(hass, entry) entry.async_on_unload( async_track_state_change_event( @@ -48,11 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: ProximityConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index a8dd85c1523..856138c9051 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -23,7 +23,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance @@ -75,16 +74,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): config_entry: ProximityConfigEntry - def __init__( - self, hass: HomeAssistant, friendly_name: str, config: ConfigType - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ProximityConfigEntry) -> None: """Initialize the Proximity coordinator.""" - self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] - self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] - self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone_id: str = config[CONF_ZONE] + self.ignored_zone_ids: list[str] = config_entry.data[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config_entry.data[CONF_TRACKED_ENTITIES] + self.tolerance: int = config_entry.data[CONF_TOLERANCE] + self.proximity_zone_id: str = config_entry.data[CONF_ZONE] self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] - self.unit_of_measurement: str = config.get( + self.unit_of_measurement: str = config_entry.data.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) self.entity_mapping: dict[str, list[str]] = defaultdict(list) @@ -92,7 +89,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): super().__init__( hass, _LOGGER, - name=friendly_name, + config_entry=config_entry, + name=config_entry.title, update_interval=None, ) @@ -166,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) return None - distance_to_zone = distance( + distance_to_centre = distance( zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE], latitude, @@ -174,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # it is ensured, that distance can't be None, since zones must have lat/lon coordinates - assert distance_to_zone is not None - return round(distance_to_zone) + assert distance_to_centre is not None + + zone_radius: float = zone.attributes["radius"] + if zone_radius > distance_to_centre: + # we've arrived the zone + return 0 + return round(distance_to_centre - zone_radius) def _calc_direction_of_travel( self, diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 55d4ca02b9b..72203a2dff4 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -82,7 +82,7 @@ def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: async def async_setup_entry( hass: HomeAssistant, entry: ProximityConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the proximity sensors.""" diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 118004e908e..5f713174f50 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -61,7 +61,7 @@ "step": { "confirm": { "title": "[%key:component::proximity::issues::tracked_entity_removed::title%]", - "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed." + "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entities were set to unavailable and can be removed." } } } diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6d6771debc4..0db6ea28652 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index e5e3d01591a..f6e909f13d1 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 1415e3dd0a6..4bb7dee411d 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -24,6 +24,7 @@ from .coordinator import ( InfoUpdateCoordinator, JobUpdateCoordinator, LegacyStatusCoordinator, + PrusaLinkUpdateCoordinator, StatusCoordinator, ) @@ -47,11 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) - coordinators = { - "legacy_status": LegacyStatusCoordinator(hass, api), - "status": StatusCoordinator(hass, api), - "job": JobUpdateCoordinator(hass, api), - "info": InfoUpdateCoordinator(hass, api), + coordinators: dict[str, PrusaLinkUpdateCoordinator] = { + "legacy_status": LegacyStatusCoordinator(hass, entry, api), + "status": StatusCoordinator(hass, entry, api), + "job": JobUpdateCoordinator(hass, entry, api), + "info": InfoUpdateCoordinator(hass, entry, api), } for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py index d40ac8a4cfa..56be36c3e9d 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator @@ -57,7 +57,7 @@ BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 06d356b2ca6..59a63d874ee 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator @@ -72,7 +72,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink buttons based on a config entry.""" coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index eee655447cc..6aac03ca179 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -7,7 +7,7 @@ from pyprusalink.types import PrinterState from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import JobUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import PrusaLinkEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink camera.""" coordinator: JobUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["job"] diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py index 1d887983931..e6f54bc6fa5 100644 --- a/homeassistant/components/prusalink/coordinator.py +++ b/homeassistant/components/prusalink/coordinator.py @@ -37,12 +37,18 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): config_entry: ConfigEntry expect_change_until = 0.0 - def __init__(self, hass: HomeAssistant, api: PrusaLink) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: PrusaLink + ) -> None: """Initialize the update coordinator.""" self.api = api super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=self._get_update_interval(None) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=self._get_update_interval(None), ) async def _async_update_data(self) -> T: diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 0c746adbe2e..b9588f72a3c 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance @@ -205,7 +205,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 0ada2885fa7..2ccf086071a 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -28,7 +28,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util import location +from homeassistant.util import location as location_util from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 @@ -103,7 +103,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate Version 1 -> Version 2: New region codes. if version == 1: - loc = await location.async_detect_location_info(async_get_clientsession(hass)) + loc = await location_util.async_detect_location_info( + async_get_clientsession(hass) + ) if loc: country = COUNTRYCODE_NAMES.get(loc.country_code) if country in COUNTRIES: diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 877fb595fc0..4e3f8f08e39 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location +from homeassistant.util import location as location_util from .const import ( CONFIG_ENTRY_VERSION, @@ -54,7 +54,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): self.region = None self.pin: str | None = None self.m_device = None - self.location: location.LocationInfo | None = None + self.location: location_util.LocationInfo | None = None self.device_list: list[str] = [] async def async_step_user( @@ -190,7 +190,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): # Try to find region automatically. if not self.location: - self.location = await location.async_detect_location_info( + self.location = await location_util.async_detect_location_info( async_get_clientsession(self.hass) ) if self.location: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 8db24beae20..4de7cbeb463 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -27,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import JsonObjectType from . import format_unique_id, load_games, save_games @@ -48,7 +48,7 @@ DEFAULT_RETRIES = 2 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PS4 from a config entry.""" config = config_entry diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py index 4ab1f905068..1974363a8e3 100644 --- a/homeassistant/components/pulseaudio_loopback/switch.py +++ b/homeassistant/components/pulseaudio_loopback/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index 4de1ce02810..4ece35a3f1c 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -2,22 +2,19 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import PureEnergieDataUpdateCoordinator +from .coordinator import PureEnergieConfigEntry, PureEnergieDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PureEnergieConfigEntry) -> bool: """Set up Pure Energie from a config entry.""" - coordinator = PureEnergieDataUpdateCoordinator(hass) + coordinator = PureEnergieDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py index a2bbb671ff7..0dcb1a9ab13 100644 --- a/homeassistant/components/pure_energie/config_flow.py +++ b/homeassistant/components/pure_energie/config_flow.py @@ -7,11 +7,11 @@ from typing import Any from gridnet import Device, GridNet, GridNetConnectionError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -58,7 +58,7 @@ class PureEnergieFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.discovered_host = discovery_info.host diff --git a/homeassistant/components/pure_energie/coordinator.py b/homeassistant/components/pure_energie/coordinator.py index fdd848eb4c6..cd66ab060eb 100644 --- a/homeassistant/components/pure_energie/coordinator.py +++ b/homeassistant/components/pure_energie/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator] + class PureEnergieData(NamedTuple): """Class for defining data in dict.""" @@ -25,16 +27,18 @@ class PureEnergieData(NamedTuple): class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): """Class to manage fetching Pure Energie data from single eindpoint.""" - config_entry: ConfigEntry + config_entry: PureEnergieConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: PureEnergieConfigEntry, ) -> None: """Initialize global Pure Energie data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py index de9134129ed..5098a298e85 100644 --- a/homeassistant/components/pure_energie/diagnostics.py +++ b/homeassistant/components/pure_energie/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import PureEnergieConfigEntry +from .coordinator import PureEnergieConfigEntry TO_REDACT = { CONF_HOST, diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 468858f117f..ad57206adeb 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -15,12 +15,15 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PureEnergieConfigEntry from .const import DOMAIN -from .coordinator import PureEnergieData, PureEnergieDataUpdateCoordinator +from .coordinator import ( + PureEnergieConfigEntry, + PureEnergieData, + PureEnergieDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) @@ -61,7 +64,7 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PureEnergieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Pure Energie Sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py index 7bf0770c6fc..f1511733cfa 100644 --- a/homeassistant/components/purpleair/coordinator.py +++ b/homeassistant/components/purpleair/coordinator.py @@ -49,16 +49,21 @@ UPDATE_INTERVAL = timedelta(minutes=2) class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): """Define a PurpleAir-specific coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" - self._entry = entry self._api = API( entry.data[CONF_API_KEY], session=aiohttp_client.async_get_clientsession(hass), ) super().__init__( - hass, LOGGER, name=entry.title, update_interval=UPDATE_INTERVAL + hass, + LOGGER, + config_entry=entry, + name=entry.title, + update_interval=UPDATE_INTERVAL, ) async def _async_update_data(self) -> GetSensorsResponse: @@ -66,7 +71,7 @@ class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): try: return await self._api.sensors.async_get_sensors( SENSOR_FIELDS_TO_RETRIEVE, - sensor_indices=self._entry.options[CONF_SENSOR_INDICES], + sensor_indices=self.config_entry.options[CONF_SENSOR_INDICES], ) except InvalidApiKeyError as err: raise ConfigEntryAuthFailed("Invalid API key") from err diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 9fb0249a360..bed1d878557 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_SENSOR_INDICES, DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator @@ -166,7 +166,7 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PurpleAir sensors based on a config entry.""" coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index b082e088ba2..006093f3545 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -6,7 +6,7 @@ "data": { "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "distance": "Search Radius" + "distance": "Search radius" }, "data_description": { "latitude": "The latitude around which to search for sensors", @@ -53,7 +53,7 @@ "options": { "step": { "add_sensor": { - "title": "Add Sensor", + "title": "Add sensor", "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", "data": { "latitude": "[%key:common::config_flow::data::latitude%]", @@ -67,7 +67,7 @@ } }, "choose_sensor": { - "title": "Choose Sensor to Add", + "title": "Choose sensor to add", "description": "[%key:component::purpleair::config::step::choose_sensor::description%]", "data": { "sensor_index": "[%key:component::purpleair::config::step::choose_sensor::data::sensor_index%]" @@ -84,9 +84,9 @@ } }, "remove_sensor": { - "title": "Remove Sensor", + "title": "Remove sensor", "data": { - "sensor_device_id": "Sensor Name" + "sensor_device_id": "Sensor name" }, "data_description": { "sensor_device_id": "The sensor to remove" diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 37ac6144d0d..603fe89d542 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 4989fc91d5e..2dbaa8fc713 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -68,7 +68,9 @@ SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pushbullet sensors from config entry.""" diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index b5c517c8662..faca654b420 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pvoutput/coordinator.py b/homeassistant/components/pvoutput/coordinator.py index 5c38792c553..ce3642421bf 100644 --- a/homeassistant/components/pvoutput/coordinator.py +++ b/homeassistant/components/pvoutput/coordinator.py @@ -21,14 +21,15 @@ class PVOutputDataUpdateCoordinator(DataUpdateCoordinator[Status]): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the PVOutput coordinator.""" - self.config_entry = entry self.pvoutput = PVOutput( api_key=entry.data[CONF_API_KEY], system_id=entry.data[CONF_SYSTEM_ID], session=async_get_clientsession(hass), ) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, LOGGER, config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL + ) async def _async_update_data(self) -> Status: """Fetch system status from PVOutput.""" diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index ef2bb3eb660..b4ed3f93945 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SYSTEM_ID, DOMAIN @@ -98,7 +98,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a PVOutput sensors based on a config entry.""" coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 6327164e3c8..4d120e9fae7 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN from .coordinator import ElecPricesDataUpdateCoordinator diff --git a/homeassistant/components/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py index 171e516abdc..28e676d37ed 100644 --- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -21,6 +21,8 @@ _LOGGER = logging.getLogger(__name__) class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] ) -> None: @@ -35,14 +37,17 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): sensor_keys=tuple(sensor_keys), ) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(minutes=30), ) - self._entry = entry @property def entry_id(self) -> str: """Return entry ID.""" - return self._entry.entry_id + return self.config_entry.entry_id async def _async_update_data(self) -> EsiosApiData: """Update electricity prices from the ESIOS API.""" diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 9d9fe5b9661..1b92cfc533d 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -148,7 +148,9 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the electricity price sensor from config_entry.""" coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8b85dfa29a4..ca7bbb0c1dc 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -2,40 +2,35 @@ from __future__ import annotations -from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI -from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +import logging + +from aiohttp import CookieJar +from pyloadapi import PyLoadAPI +from yarl import URL -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import DOMAIN -from .coordinator import PyLoadCoordinator +from .coordinator import PyLoadConfigEntry, PyLoadCoordinator + +_LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -type PyLoadConfigEntry = ConfigEntry[PyLoadCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Set up pyLoad from a config entry.""" - url = ( - f"{"https" if entry.data[CONF_SSL] else "http"}://" - f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" - ) - session = async_create_clientsession( hass, verify_ssl=entry.data[CONF_VERIFY_SSL], @@ -43,30 +38,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo ) pyloadapi = PyLoadAPI( session, - api_url=url, + api_url=URL(entry.data[CONF_URL]), username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) - try: - await pyloadapi.login() - except CannotConnect as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_request_exception", - ) from e - except ParserError as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_parse_exception", - ) from e - except InvalidAuth as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, - ) from e - coordinator = PyLoadCoordinator(hass, pyloadapi) + coordinator = PyLoadCoordinator(hass, entry, pyloadapi) await coordinator.async_config_entry_first_refresh() @@ -79,3 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version == 1 and entry.minor_version == 0: + url = URL.build( + scheme="https" if entry.data[CONF_SSL] else "http", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ).human_repr() + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_URL: url}, minor_version=1, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + return True diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 386fe6968de..5ee10a327d1 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -7,17 +7,19 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PyLoadConfigEntry from .const import DOMAIN +from .coordinator import PyLoadConfigEntry from .entity import BasePyLoadEntity +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class PyLoadButtonEntityDescription(ButtonEntityDescription): @@ -63,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PyLoadConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons from a config entry.""" diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index c8d08f997f9..50d354d345d 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -7,38 +7,38 @@ import logging from typing import Any from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI -from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT, - CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, TextSelectorType, ) -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), vol.Required(CONF_VERIFY_SSL, default=True): bool, vol.Required(CONF_USERNAME): TextSelector( TextSelectorConfig( @@ -81,14 +81,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non user_input[CONF_VERIFY_SSL], cookie_jar=CookieJar(unsafe=True), ) - - url = ( - f"{"https" if user_input[CONF_SSL] else "http"}://" - f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" - ) pyload = PyLoadAPI( session, - api_url=url, + api_url=URL(user_input[CONF_URL]), username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], ) @@ -100,6 +95,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for pyLoad.""" VERSION = 1 + MINOR_VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -107,9 +103,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + url = URL(user_input[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) try: await validate_input(self.hass, user_input) except (CannotConnect, ParserError): @@ -121,7 +116,14 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: title = DEFAULT_NAME - return self.async_create_entry(title=title, data=user_input) + + return self.async_create_entry( + title=title, + data={ + **user_input, + CONF_URL: url, + }, + ) return self.async_show_form( step_id="user", @@ -145,9 +147,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - new_input = reauth_entry.data | user_input try: - await validate_input(self.hass, new_input) + await validate_input(self.hass, {**reauth_entry.data, **user_input}) except (CannotConnect, ParserError): errors["base"] = "cannot_connect" except InvalidAuth: @@ -156,7 +157,9 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_update_reload_and_abort(reauth_entry, data=new_input) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) return self.async_show_form( step_id="reauth_confirm", @@ -192,15 +195,18 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): else: return self.async_update_reload_and_abort( reconfig_entry, - data=user_input, + data={ + **user_input, + CONF_URL: URL(user_input[CONF_URL]).human_repr(), + }, reload_even_if_entry_is_unchanged=False, ) - + suggested_values = user_input if user_input else reconfig_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_USER_DATA_SCHEMA, - user_input or reconfig_entry.data, + suggested_values, ), description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 7eadefcd260..c57dfa7720d 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -9,7 +9,7 @@ from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -34,16 +34,22 @@ class PyLoadData: free_space: int +type PyLoadConfigEntry = ConfigEntry[PyLoadCoordinator] + + class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): """pyLoad coordinator.""" - config_entry: ConfigEntry + config_entry: PyLoadConfigEntry - def __init__(self, hass: HomeAssistant, pyload: PyLoadAPI) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: PyLoadConfigEntry, pyload: PyLoadAPI + ) -> None: """Initialize pyLoad coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) @@ -53,14 +59,11 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): async def _async_update_data(self) -> PyLoadData: """Fetch data from API endpoint.""" try: - if not self.version: - self.version = await self.pyload.version() return PyLoadData( **await self.pyload.get_status(), free_space=await self.pyload.free_space(), ) - - except InvalidAuth as e: + except InvalidAuth: try: await self.pyload.login() except InvalidAuth as exc: @@ -69,13 +72,42 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): translation_key="setup_authentication_exception", translation_placeholders={CONF_USERNAME: self.pyload.username}, ) from exc - - raise UpdateFailed( - "Unable to retrieve data due to cookie expiration" - ) from e + _LOGGER.debug( + "Unable to retrieve data due to cookie expiration, retrying after 20 seconds" + ) + return self.data except CannotConnect as e: raise UpdateFailed( - "Unable to connect and retrieve data from pyLoad API" + translation_domain=DOMAIN, + translation_key="setup_request_exception", ) from e except ParserError as e: - raise UpdateFailed("Unable to parse data from pyLoad API") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + try: + await self.pyload.login() + self.version = await self.pyload.version() + except CannotConnect as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except ParserError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + except InvalidAuth as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={ + CONF_USERNAME: self.config_entry.data[CONF_USERNAME] + }, + ) from e diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index e9688a3369b..98fab38da1d 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -5,14 +5,15 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import PyLoadConfigEntry -from .coordinator import PyLoadData +from .coordinator import PyLoadConfigEntry, PyLoadData -TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST} +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_URL} async def async_get_config_entry_diagnostics( @@ -22,6 +23,9 @@ async def async_get_config_entry_diagnostics( pyload_data: PyLoadData = config_entry.runtime_data.data return { - "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "config_entry_data": { + **async_redact_data(dict(config_entry.data), TO_REDACT), + CONF_URL: URL(config_entry.data[CONF_URL]).with_host(REDACTED).human_repr(), + }, "pyload_data": asdict(pyload_data), } diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index e21167cf10b..feaa23af7de 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], - "requirements": ["PyLoadAPI==1.3.2"] + "quality_scale": "platinum", + "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/quality_scale.yaml b/homeassistant/components/pyload/quality_scale.yaml new file mode 100644 index 00000000000..a9ce552961b --- /dev/null +++ b/homeassistant/components/pyload/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration registers no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration registers no events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration registers no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + discovery: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration is a web service, there are no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration is a web service, there are no devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: The integration has no repairs. + stale-devices: + status: exempt + comment: The integration is a web service, there are no devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index 38f681d30d5..7425c543fe1 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -14,14 +14,15 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import PyLoadConfigEntry from .const import UNIT_DOWNLOADS -from .coordinator import PyLoadData +from .coordinator import PyLoadConfigEntry, PyLoadData from .entity import BasePyLoadEntity +PARALLEL_UPDATES = 0 + class PyLoadSensorEntity(StrEnum): """pyLoad Sensor Entities.""" @@ -86,7 +87,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PyLoadConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the pyLoad sensors.""" diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 0fd9b4befcf..9414f7f7bb8 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -3,30 +3,30 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", + "username": "The username used to access the pyLoad instance.", + "password": "The password associated with the pyLoad account.", + "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "url": "[%key:component::pyload::config::step::user::data_description::url%]", + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]", + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]" } }, "reauth_confirm": { @@ -34,6 +34,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" } } }, @@ -91,10 +95,10 @@ }, "exceptions": { "setup_request_exception": { - "message": "Unable to connect and retrieve data from pyLoad API, try again later" + "message": "Unable to connect and retrieve data from pyLoad API" }, "setup_parse_exception": { - "message": "Unable to parse data from pyLoad API, try again later" + "message": "Unable to parse data from pyLoad API" }, "setup_authentication_exception": { "message": "Authentication failed for {username}, verify your login credentials" diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index ea189ed9a8f..46a54451b9a 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.switch import ( SwitchDeviceClass, @@ -16,13 +16,14 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PyLoadConfigEntry from .const import DOMAIN -from .coordinator import PyLoadData +from .coordinator import PyLoadConfigEntry, PyLoadData from .entity import BasePyLoadEntity +PARALLEL_UPDATES = 1 + class PyLoadSwitch(StrEnum): """PyLoad Switch Entities.""" @@ -66,7 +67,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PyLoadConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the pyLoad sensors.""" diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index f9e6a994406..0729d73a034 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -36,8 +36,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import raise_if_invalid_filename -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, raise_if_invalid_filename from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -239,20 +238,13 @@ def execute( if name.startswith("async_"): raise ScriptError("Not allowed to access async methods") if ( - obj is hass - and name not in ALLOWED_HASS - or obj is hass.bus - and name not in ALLOWED_EVENTBUS - or obj is hass.states - and name not in ALLOWED_STATEMACHINE - or obj is hass.services - and name not in ALLOWED_SERVICEREGISTRY - or obj is dt_util - and name not in ALLOWED_DT_UTIL - or obj is datetime - and name not in ALLOWED_DATETIME - or isinstance(obj, TimeWrapper) - and name not in ALLOWED_TIME + (obj is hass and name not in ALLOWED_HASS) + or (obj is hass.bus and name not in ALLOWED_EVENTBUS) + or (obj is hass.states and name not in ALLOWED_STATEMACHINE) + or (obj is hass.services and name not in ALLOWED_SERVICEREGISTRY) + or (obj is dt_util and name not in ALLOWED_DT_UTIL) + or (obj is datetime and name not in ALLOWED_DATETIME) + or (isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME) ): raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}") diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 4348fdd9911..c8cb1da40c9 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==7.4"] + "requirements": ["RestrictedPython==8.0"] } diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index d95136965f8..513b49d3561 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -124,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except APIConnectionError as exc: raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc - coordinator = QBittorrentDataCoordinator(hass, client) + coordinator = QBittorrentDataCoordinator(hass, config_entry, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index c590bb9d81a..8fd23fb3b5b 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -15,6 +15,7 @@ from qbittorrentapi import ( ) from qbittorrentapi.torrents import TorrentStatusesT +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -27,7 +28,11 @@ _LOGGER = logging.getLogger(__name__) class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): """Coordinator for updating QBittorrent data.""" - def __init__(self, hass: HomeAssistant, client: Client) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: Client + ) -> None: """Initialize coordinator.""" self.client = client self._is_alternative_mode_enabled = False @@ -42,6 +47,7 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 67eb856bb83..23ec485fcd4 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,10 +14,10 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" +SENSOR_TYPE_CONNECTION_STATUS = "connection_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit" +SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit" +SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download" +SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload" +SENSOR_TYPE_GLOBAL_RATIO = "global_ratio" SENSOR_TYPE_ALL_TORRENTS = "all_torrents" SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" @@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str: return STATE_IDLE -def get_dl(coordinator: QBittorrentDataCoordinator) -> int: +def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(str, server_state.get("connection_status")) + + +def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current download speed.""" server_state = cast(Mapping, coordinator.data.get("server_state")) return cast(int, server_state.get("dl_info_speed")) -def get_up(coordinator: QBittorrentDataCoordinator) -> int: +def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current upload speed.""" server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) return cast(int, server_state.get("up_info_speed")) +def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("dl_rate_limit")) + + +def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current upload speed.""" + server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) + return cast(int, server_state.get("up_rate_limit")) + + +def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_dl")) + + +def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_ul")) + + +def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(float, server_state.get("global_ratio")) + + @dataclass(frozen=True, kw_only=True) class QBittorrentSensorEntityDescription(SensorEntityDescription): """Entity description class for qBittorent sensors.""" @@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], value_fn=get_state, ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_CONNECTION_STATUS, + translation_key="connection_status", + device_class=SensorDeviceClass.ENUM, + options=["connected", "firewalled", "disconnected"], + value_fn=get_connection_status, + ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, translation_key="download_speed", @@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_dl, + value_fn=get_download_speed, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, @@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_up, + value_fn=get_upload_speed, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT, + translation_key="download_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_download_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT, + translation_key="upload_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_upload_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_DOWNLOAD, + translation_key="alltime_download", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES, + value_fn=get_alltime_download, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_UPLOAD, + translation_key="alltime_upload", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement="B", + suggested_display_precision=2, + suggested_unit_of_measurement="TiB", + value_fn=get_alltime_upload, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_GLOBAL_RATIO, + translation_key="global_ratio", + state_class=SensorStateClass.MEASUREMENT, + value_fn=get_global_ratio, + entity_registry_enabled_default=False, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, @@ -129,7 +227,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 9c9ee371737..ee613eb96c2 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -26,6 +26,21 @@ "upload_speed": { "name": "Upload speed" }, + "download_speed_limit": { + "name": "Download speed limit" + }, + "upload_speed_limit": { + "name": "Upload speed limit" + }, + "alltime_download": { + "name": "All-time download" + }, + "alltime_upload": { + "name": "All-time upload" + }, + "global_ratio": { + "name": "Global ratio" + }, "current_status": { "name": "Status", "state": { @@ -35,6 +50,14 @@ "downloading": "Downloading" } }, + "connection_status": { + "name": "Connection status", + "state": { + "connected": "Connected", + "firewalled": "Firewalled", + "disconnected": "Disconnected" + } + }, "active_torrents": { "name": "Active torrents", "unit_of_measurement": "torrents" @@ -86,16 +109,16 @@ }, "exceptions": { "invalid_device": { - "message": "No device with id {device_id} was found" + "message": "No device with ID {device_id} was found" }, "invalid_entry_id": { - "message": "No entry with id {device_id} was found" + "message": "No entry with ID {device_id} was found" }, "login_error": { - "message": "A login error occured. Please check you username and password." + "message": "A login error occurred. Please check your username and password." }, "cannot_connect": { - "message": "Can't connect to QBittorrent, please check your configuration." + "message": "Can't connect to qBittorrent, please check your configuration." } } } diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py index f12118e5233..dd61f130ca1 100644 --- a/homeassistant/components/qbittorrent/switch.py +++ b/homeassistant/components/qbittorrent/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -43,7 +43,7 @@ SWITCH_TYPES: tuple[QBittorrentSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent switch entries.""" diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py new file mode 100644 index 00000000000..f77f439ecc1 --- /dev/null +++ b/homeassistant/components/qbus/__init__.py @@ -0,0 +1,84 @@ +"""The Qbus integration.""" + +import logging + +from homeassistant.components.mqtt import async_wait_for_mqtt_client +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import ( + QBUS_KEY, + QbusConfigCoordinator, + QbusConfigEntry, + QbusControllerCoordinator, +) + +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Qbus integration. + + We set up a single coordinator for managing Qbus config updates. The + config update contains the configuration for all controllers (and + config entries). This avoids having each device requesting and managing + the config on its own. + """ + _LOGGER.debug("Loading integration") + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + return False + + config_coordinator = QbusConfigCoordinator.get_or_create(hass) + await config_coordinator.async_subscribe_to_config() + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Set up Qbus from a config entry.""" + _LOGGER.debug("%s - Loading entry", entry.unique_id) + + if not await async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + coordinator = QbusControllerCoordinator(hass, entry) + entry.runtime_data = coordinator + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Get current config + config = await QbusConfigCoordinator.get_or_create( + hass + ).async_get_or_request_config() + + # Update the controller config + if config: + await coordinator.async_update_controller_config(config) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("%s - Unloading entry", entry.unique_id) + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry.runtime_data.shutdown() + _cleanup(hass, entry) + + return unload_ok + + +def _cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Shutdown if no more entries are loaded.""" + if not hass.config_entries.async_loaded_entries(DOMAIN) and ( + config_coordinator := hass.data.get(QBUS_KEY) + ): + config_coordinator.shutdown() diff --git a/homeassistant/components/qbus/config_flow.py b/homeassistant/components/qbus/config_flow.py new file mode 100644 index 00000000000..2f08c5b47e2 --- /dev/null +++ b/homeassistant/components/qbus/config_flow.py @@ -0,0 +1,160 @@ +"""Config flow for Qbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from qbusmqttapi.discovery import QbusMqttDevice +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import client as mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ID +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import QbusConfigCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class QbusFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle Qbus config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._gateway_topic = self._topic_factory.get_gateway_state_topic() + self._config_topic = self._topic_factory.get_config_topic() + self._device_topic = self._topic_factory.get_device_state_topic("+") + + self._device: QbusMqttDevice | None = None + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + _LOGGER.debug("Running mqtt discovery for topic %s", discovery_info.topic) + + # Abort if the payload is empty + if not discovery_info.payload: + _LOGGER.debug("Payload empty") + return self.async_abort(reason="invalid_discovery_info") + + match discovery_info.subscribed_topic: + case self._gateway_topic: + return await self._async_handle_gateway_topic(discovery_info) + + case self._config_topic: + return await self._async_handle_config_topic(discovery_info) + + case self._device_topic: + return await self._async_handle_device_topic(discovery_info) + + return self.async_abort(reason="invalid_discovery_info") + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._device is not None + + if user_input is not None: + return self.async_create_entry( + title=f"Controller {self._device.serial_number}", + data={ + CONF_SERIAL_NUMBER: self._device.serial_number, + CONF_ID: self._device.id, + }, + ) + + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + CONF_SERIAL_NUMBER: self._device.serial_number, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") + + async def _async_handle_gateway_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling gateway state") + gateway_state = self._message_factory.parse_gateway_state( + discovery_info.payload + ) + + if gateway_state is not None and gateway_state.online is True: + _LOGGER.debug("Requesting config") + await mqtt.async_publish( + self.hass, self._topic_factory.get_get_config_topic(), b"" + ) + + # Abort to wait for config topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_config_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Handling config topic") + qbus_config = self._message_factory.parse_discovery(discovery_info.payload) + + if qbus_config is not None: + QbusConfigCoordinator.get_or_create(self.hass).store_config(qbus_config) + + _LOGGER.debug("Requesting device states") + device_ids = [x.id for x in qbus_config.devices] + request = self._message_factory.create_state_request(device_ids) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + # Abort to wait for device topic + return self.async_abort(reason="discovery_in_progress") + + async def _async_handle_device_topic( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + _LOGGER.debug("Discovering device") + qbus_config = await QbusConfigCoordinator.get_or_create( + self.hass + ).async_get_or_request_config() + + if qbus_config is None: + _LOGGER.error("Qbus config not ready") + return self.async_abort(reason="invalid_discovery_info") + + device_id = discovery_info.topic.split("/")[2] + self._device = qbus_config.get_device_by_id(device_id) + + if self._device is None: + _LOGGER.warning("Device with id '%s' not found in config", device_id) + return self.async_abort(reason="invalid_discovery_info") + + await self.async_set_unique_id(self._device.serial_number) + + # Do not use error message "already_configured" (which is the + # default), as this will result in unsubscribing from the triggered + # mqtt topic. The topic subscribed to has a wildcard to allow + # discovery of multiple devices. Unsubscribing would result in + # not discovering new or unconfigured devices. + self._abort_if_unique_id_configured(error="device_already_configured") + + self.context.update( + { + "title_placeholders": { + CONF_SERIAL_NUMBER: self._device.serial_number, + } + } + ) + + return await self.async_step_discovery_confirm() diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py new file mode 100644 index 00000000000..b9e42f13766 --- /dev/null +++ b/homeassistant/components/qbus/const.py @@ -0,0 +1,15 @@ +"""Constants for the Qbus integration.""" + +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "qbus" +PLATFORMS: list[Platform] = [ + Platform.LIGHT, + Platform.SWITCH, +] + +CONF_SERIAL_NUMBER: Final = "serial" + +MANUFACTURER: Final = "Qbus" diff --git a/homeassistant/components/qbus/coordinator.py b/homeassistant/components/qbus/coordinator.py new file mode 100644 index 00000000000..dd57a98787b --- /dev/null +++ b/homeassistant/components/qbus/coordinator.py @@ -0,0 +1,279 @@ +"""Qbus coordinator.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import cast + +from qbusmqttapi.discovery import QbusDiscovery, QbusMqttDevice, QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory + +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_wait_for_mqtt_client, + client as mqtt, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.hass_dict import HassKey + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +type QbusConfigEntry = ConfigEntry[QbusControllerCoordinator] +QBUS_KEY: HassKey[QbusConfigCoordinator] = HassKey(DOMAIN) + + +class QbusControllerCoordinator(DataUpdateCoordinator[list[QbusMqttOutput]]): + """Qbus data coordinator.""" + + _STATE_REQUEST_DELAY = 3 + + def __init__(self, hass: HomeAssistant, entry: QbusConfigEntry) -> None: + """Initialize Qbus coordinator.""" + + _LOGGER.debug("%s - Initializing coordinator", entry.unique_id) + self.config_entry: QbusConfigEntry + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=entry.unique_id or entry.entry_id, + always_update=False, + ) + + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller: QbusMqttDevice | None = None + + # Clean up when HA stops + self.config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + async def _async_update_data(self) -> list[QbusMqttOutput]: + return self._controller.outputs if self._controller else [] + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus coordinator.""" + _LOGGER.debug( + "%s - Shutting down entry coordinator", self.config_entry.unique_id + ) + + self._controller_activated = False + self._subscribed_to_controller_state = False + self._controller = None + + async def async_update_controller_config(self, config: QbusDiscovery) -> None: + """Update the controller based on the config.""" + _LOGGER.debug("%s - Updating config", self.config_entry.unique_id) + serial = self.config_entry.data.get(CONF_SERIAL_NUMBER, "") + controller = config.get_device_by_serial(serial) + + if controller is None: + _LOGGER.warning( + "%s - Controller with serial %s not found", + self.config_entry.unique_id, + serial, + ) + return + + self._controller = controller + + self._update_device_info() + await self._async_subscribe_to_controller_state() + await self.async_refresh() + self._request_controller_state() + self._request_entity_states() + + def _update_device_info(self) -> None: + if self._controller is None: + return + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, format_mac(self._controller.mac))}, + manufacturer=MANUFACTURER, + model="CTD3.x", + name=f"CTD {self._controller.serial_number}", + serial_number=self._controller.serial_number, + sw_version=self._controller.version, + ) + + async def _async_subscribe_to_controller_state(self) -> None: + if self._controller is None or self._subscribed_to_controller_state is True: + return + + controller_state_topic = self._topic_factory.get_device_state_topic( + self._controller.id + ) + _LOGGER.debug( + "%s - Subscribing to %s", + self.config_entry.unique_id, + controller_state_topic, + ) + self._subscribed_to_controller_state = True + self.config_entry.async_on_unload( + await mqtt.async_subscribe( + self.hass, + controller_state_topic, + self._controller_state_received, + ) + ) + + async def _controller_state_received(self, msg: ReceiveMessage) -> None: + _LOGGER.debug( + "%s - Receiving controller state %s", self.config_entry.unique_id, msg.topic + ) + + if self._controller is None or self._controller_activated: + return + + state = self._message_factory.parse_device_state(msg.payload) + + if state and state.properties and state.properties.connectable is False: + _LOGGER.debug( + "%s - Activating controller %s", self.config_entry.unique_id, state.id + ) + self._controller_activated = True + request = self._message_factory.create_device_activate_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + def _request_entity_states(self) -> None: + async def request_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting %s entity states", + self.config_entry.unique_id, + len(self._controller.outputs), + ) + + request = self._message_factory.create_state_request( + [item.id for item in self._controller.outputs] + ) + + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller and len(self._controller.outputs) > 0: + async_call_later(self.hass, self._STATE_REQUEST_DELAY, request_state) + + def _request_controller_state(self) -> None: + async def request_controller_state(_: datetime) -> None: + if self._controller is None: + return + + _LOGGER.debug( + "%s - Requesting controller state", self.config_entry.unique_id + ) + request = self._message_factory.create_device_state_request( + self._controller + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) + + if self._controller: + async_call_later( + self.hass, self._STATE_REQUEST_DELAY, request_controller_state + ) + + +class QbusConfigCoordinator: + """Class responsible for Qbus config updates.""" + + _qbus_config: QbusDiscovery | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize config coordinator.""" + + self._hass = hass + self._message_factory = QbusMqttMessageFactory() + self._topic_factory = QbusMqttTopicFactory() + self._cleanup_callbacks: list[CALLBACK_TYPE] = [] + + self._cleanup_callbacks.append( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + ) + + @classmethod + def get_or_create(cls, hass: HomeAssistant) -> QbusConfigCoordinator: + """Get the coordinator and create if necessary.""" + if (coordinator := hass.data.get(QBUS_KEY)) is None: + coordinator = cls(hass) + hass.data[QBUS_KEY] = coordinator + + return coordinator + + def shutdown(self, event: Event | None = None) -> None: + """Shutdown Qbus config coordinator.""" + _LOGGER.debug("Shutting down Qbus config coordinator") + while self._cleanup_callbacks: + cleanup_callback = self._cleanup_callbacks.pop() + cleanup_callback() + + async def async_subscribe_to_config(self) -> None: + """Subscribe to config changes.""" + config_topic = self._topic_factory.get_config_topic() + _LOGGER.debug("Subscribing to %s", config_topic) + + self._cleanup_callbacks.append( + await mqtt.async_subscribe(self._hass, config_topic, self._config_received) + ) + + async def async_get_or_request_config(self) -> QbusDiscovery | None: + """Get or request Qbus config.""" + _LOGGER.debug("Requesting Qbus config") + + # Config already available + if self._qbus_config: + _LOGGER.debug("Qbus config already available") + return self._qbus_config + + if not await async_wait_for_mqtt_client(self._hass): + _LOGGER.debug("MQTT client not ready yet") + return None + + # Request config + _LOGGER.debug("Publishing config request") + await mqtt.async_publish( + self._hass, self._topic_factory.get_get_config_topic(), b"" + ) + + return self._qbus_config + + def store_config(self, config: QbusDiscovery) -> None: + "Store the Qbus config." + _LOGGER.debug("Storing config") + + self._qbus_config = config + + async def _config_received(self, msg: ReceiveMessage) -> None: + """Handle the received MQTT message containing the Qbus config.""" + _LOGGER.debug("Receiving Qbus config") + + config = self._message_factory.parse_discovery(msg.payload) + + if config is None: + _LOGGER.debug("Incomplete Qbus config") + return + + self.store_config(config) + + for entry in self._hass.config_entries.async_loaded_entries(DOMAIN): + entry = cast(QbusConfigEntry, entry) + await entry.runtime_data.async_update_controller_config(config) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py new file mode 100644 index 00000000000..4ab1913c4dc --- /dev/null +++ b/homeassistant/components/qbus/entity.py @@ -0,0 +1,103 @@ +"""Base class for Qbus entities.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +import re + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory +from qbusmqttapi.state import QbusMqttState + +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, MANUFACTURER +from .coordinator import QbusControllerCoordinator + +_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") + + +def add_new_outputs( + coordinator: QbusControllerCoordinator, + added_outputs: list[QbusMqttOutput], + filter_fn: Callable[[QbusMqttOutput], bool], + entity_type: type[QbusEntity], + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Call async_add_entities for new outputs.""" + + added_ref_ids = {k.ref_id for k in added_outputs} + + new_outputs = [ + output + for output in coordinator.data + if filter_fn(output) and output.ref_id not in added_ref_ids + ] + + if new_outputs: + added_outputs.extend(new_outputs) + async_add_entities([entity_type(output) for output in new_outputs]) + + +def format_ref_id(ref_id: str) -> str | None: + """Format the Qbus ref_id.""" + matches: list[str] = re.findall(_REFID_REGEX, ref_id) + + if len(matches) > 0: + if ref_id := matches[0]: + return ref_id.replace("/", "-") + + return None + + +class QbusEntity(Entity, ABC): + """Representation of a Qbus entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize the Qbus entity.""" + + self._topic_factory = QbusMqttTopicFactory() + self._message_factory = QbusMqttMessageFactory() + + ref_id = format_ref_id(mqtt_output.ref_id) + + self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + + self._attr_device_info = DeviceInfo( + name=mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, + suggested_area=mqtt_output.location.title(), + via_device=(DOMAIN, format_mac(mqtt_output.device.mac)), + ) + + self._mqtt_output = mqtt_output + self._state_topic = self._topic_factory.get_output_state_topic( + mqtt_output.device.id, mqtt_output.id + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.async_on_remove( + await mqtt.async_subscribe( + self.hass, self._state_topic, self._state_received + ) + ) + + @abstractmethod + async def _state_received(self, msg: ReceiveMessage) -> None: + pass + + async def _async_publish_output_state(self, state: QbusMqttState) -> None: + request = self._message_factory.create_set_output_state_request( + self._mqtt_output.device, state + ) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py new file mode 100644 index 00000000000..5ec76f5e807 --- /dev/null +++ b/homeassistant/components/qbus/light.py @@ -0,0 +1,110 @@ +"""Support for Qbus light.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttAnalogState, StateType + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up light entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "analog", + QbusLight, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusLight(QbusEntity, LightEntity): + """Representation of a Qbus light entity.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize light entity.""" + + super().__init__(mqtt_output) + + self._set_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + + percentage: int | None = None + on: bool | None = None + + state = QbusMqttAnalogState(id=self._mqtt_output.id) + + if brightness is None: + on = True + + state.type = StateType.ACTION + state.write_on_off(on) + else: + percentage = round(brightness_to_value((1, 100), brightness)) + + state.type = StateType.STATE + state.write_percentage(percentage) + + await self._async_publish_output_state(state) + self._set_state(percentage=percentage, on=on) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + state = QbusMqttAnalogState(id=self._mqtt_output.id, type=StateType.ACTION) + state.write_on_off(on=False) + + await self._async_publish_output_state(state) + self._set_state(on=False) + + async def _state_received(self, msg: ReceiveMessage) -> None: + output = self._message_factory.parse_output_state( + QbusMqttAnalogState, msg.payload + ) + + if output is not None: + percentage = round(output.read_percentage()) + self._set_state(percentage=percentage) + self.async_schedule_update_ha_state() + + def _set_state( + self, *, percentage: int | None = None, on: bool | None = None + ) -> None: + if percentage is None: + # When turning on without brightness, we don't know the desired + # brightness. It will be set during _state_received(). + if on is True: + self._attr_is_on = True + else: + self._attr_is_on = False + self._attr_brightness = 0 + else: + self._attr_is_on = percentage > 0 + self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json new file mode 100644 index 00000000000..17101da7c33 --- /dev/null +++ b/homeassistant/components/qbus/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "qbus", + "name": "Qbus", + "codeowners": ["@Qbus-iot", "@thomasddn"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/qbus", + "integration_type": "hub", + "iot_class": "local_push", + "mqtt": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state" + ], + "quality_scale": "bronze", + "requirements": ["qbusmqttapi==1.3.0"] +} diff --git a/homeassistant/components/qbus/quality_scale.yaml b/homeassistant/components/qbus/quality_scale.yaml new file mode 100644 index 00000000000..7e106ef6b93 --- /dev/null +++ b/homeassistant/components/qbus/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: | + The integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: | + The integration relies solely on auto-discovery. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + docs-installation-parameters: + status: exempt + comment: There are no parameters. + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: exempt + comment: The integration uses the name of what the user configured in the closed system. + exception-translations: todo + icon-translations: + status: exempt + comment: The integration creates unknown number of entities based on what is in the closed system and does not know what each entity stands for. + reconfiguration-flow: + status: exempt + comment: The integration has no settings. + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: done diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json new file mode 100644 index 00000000000..e6df18c393c --- /dev/null +++ b/homeassistant/components/qbus/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "flow_title": "Controller {serial}", + "step": { + "discovery_confirm": { + "title": "Add controller", + "description": "Add controller {serial}?" + } + }, + "abort": { + "already_configured": "Controller already configured", + "discovery_in_progress": "Discovery in progress", + "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documentation." + }, + "error": { + "no_controller": "No controllers were found" + } + } +} diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py new file mode 100644 index 00000000000..002ad43e904 --- /dev/null +++ b/homeassistant/components/qbus/switch.py @@ -0,0 +1,77 @@ +"""Support for Qbus switch.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttOnOffState, StateType + +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switch entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "onoff", + QbusSwitch, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusSwitch(QbusEntity, SwitchEntity): + """Representation of a Qbus switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize switch entity.""" + + super().__init__(mqtt_output) + + self._attr_is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(True) + + await self._async_publish_output_state(state) + self._attr_is_on = True + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + state = QbusMqttOnOffState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_value(False) + + await self._async_publish_output_state(state) + self._attr_is_on = False + + async def _state_received(self, msg: ReceiveMessage) -> None: + output = self._message_factory.parse_output_state( + QbusMqttOnOffState, msg.payload + ) + + if output is not None: + self._attr_is_on = output.read_value() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py index 5f1367fbce8..3431204595a 100644 --- a/homeassistant/components/qingping/binary_sensor.py +++ b/homeassistant/components/qingping/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import QingpingConfigEntry @@ -74,7 +74,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: QingpingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Qingping BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index c5efe03a878..990eb5116eb 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -98,7 +98,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/qingping/sensor.py b/homeassistant/components/qingping/sensor.py index 3d5f30c61fc..ee2a63b169a 100644 --- a/homeassistant/components/qingping/sensor.py +++ b/homeassistant/components/qingping/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import QingpingConfigEntry @@ -142,7 +142,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: QingpingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Qingping BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index c1266ab951b..c235d441133 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 8c2bf81a47f..297f6569d2b 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -33,7 +33,13 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the qnap coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) protocol = "https" if config_entry.data[CONF_SSL] else "http" self._api = QNAPStats( diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 383a4e5f572..381455cb7e1 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -248,7 +248,7 @@ SENSOR_KEYS: list[str] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = QnapCoordinator(hass, config_entry) diff --git a/homeassistant/components/qnap_qsw/__init__.py b/homeassistant/components/qnap_qsw/__init__.py index d7352435b07..f9faca025a5 100644 --- a/homeassistant/components/qnap_qsw/__init__.py +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -35,10 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: qsw = QnapQswApi(aiohttp_client.async_get_clientsession(hass), options) - coord_data = QswDataCoordinator(hass, qsw) + coord_data = QswDataCoordinator(hass, entry, qsw) await coord_data.async_config_entry_first_refresh() - coord_fw = QswFirmwareCoordinator(hass, qsw) + coord_fw = QswFirmwareCoordinator(hass, entry, qsw) try: await coord_fw.async_config_entry_first_refresh() except ConfigEntryNotReady as error: diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index a9c025b86ce..c1f77d068df 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA @@ -78,7 +78,9 @@ PORT_BINARY_SENSOR_TYPES: Final[tuple[QswBinarySensorEntityDescription, ...]] = async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW binary sensors from a config_entry.""" coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index 091c6786a92..02cf96766f2 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, QSW_COORD_DATA, QSW_REBOOT from .coordinator import QswDataCoordinator @@ -41,7 +41,9 @@ BUTTON_TYPES: Final[tuple[QswButtonDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW buttons from a config_entry.""" coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 3a10e54ac82..3ccb13e0f64 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -9,12 +9,12 @@ from aioqsw.exceptions import LoginError, QswError from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -73,7 +73,7 @@ class QNapQSWConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" self._discovered_url = f"http://{discovery_info.ip}" diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index c873f2a773d..b72bed7105c 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -10,6 +10,7 @@ from typing import Any from aioqsw.exceptions import QswError from aioqsw.localapi import QnapQswApi +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,13 +25,18 @@ _LOGGER = logging.getLogger(__name__) class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the QNAP QSW device.""" - def __init__(self, hass: HomeAssistant, qsw: QnapQswApi) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + ) -> None: """Initialize.""" self.qsw = qsw super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DATA_SCAN_INTERVAL, ) @@ -48,13 +54,18 @@ class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching firmware data from the QNAP QSW device.""" - def __init__(self, hass: HomeAssistant, qsw: QnapQswApi) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + ) -> None: """Initialize.""" self.qsw = qsw super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=FW_SCAN_INTERVAL, ) diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index e7f2c18638f..af02c121656 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -44,7 +44,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util @@ -286,7 +286,9 @@ PORT_SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW sensors from a config_entry.""" coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index ac789235271..c5cef729849 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -20,7 +20,7 @@ from homeassistant.components.update import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, QSW_COORD_FW, QSW_UPDATE from .coordinator import QswFirmwareCoordinator @@ -36,7 +36,9 @@ UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW updates from a config_entry.""" coordinator: QswFirmwareCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index dc68472d94e..6491dca2e2c 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py index 9aad94790c6..98f0bcbaf99 100644 --- a/homeassistant/components/qvr_pro/__init__.py +++ b/homeassistant/components/qvr_pro/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index 776e32dded1..d3cf2ff3d9b 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -18,8 +18,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py index 3a2ec5a9206..ff7a1d2e98a 100644 --- a/homeassistant/components/qwikswitch/entity.py +++ b/homeassistant/components/qwikswitch/entity.py @@ -35,7 +35,7 @@ class QSEntity(Entity): """Receive update packet from QSUSB. Match dispather_send signature.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Listen for updates from QSUSb via dispatcher.""" self.async_on_remove( async_dispatcher_connect(self.hass, self.qsid, self.update_packet) diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 64e560b4f08..64b95fb17f6 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -48,9 +48,9 @@ class QSSensor(QSEntity, SensorEntity): self._decode, self.unit = SENSORS[sensor_type] # this cannot happen because it only happens in bool and this should be redirected to binary_sensor - assert not isinstance( - self.unit, type - ), f"boolean sensor id={sensor['id']} name={sensor['name']}" + assert not isinstance(self.unit, type), ( + f"boolean sensor id={sensor['id']} name={sensor['name']}" + ) @callback def update_packet(self, packet): diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py index e4eb67a67f5..d6530b322b0 100644 --- a/homeassistant/components/rabbitair/__init__.py +++ b/homeassistant/components/rabbitair/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: zeroconf_instance = await zeroconf.async_get_async_instance(hass) device: Client = UdpClient(host, token, zeroconf=zeroconf_instance) - coordinator = RabbitAirDataUpdateCoordinator(hass, device) + coordinator = RabbitAirDataUpdateCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 1bee69219b0..f4487a73b58 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -99,7 +100,7 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" mac = dr.format_mac(discovery_info.properties["id"]) diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py index 3c7db126c7d..75453fe4d24 100644 --- a/homeassistant/components/rabbitair/coordinator.py +++ b/homeassistant/components/rabbitair/coordinator.py @@ -7,6 +7,7 @@ from typing import Any, cast from rabbitair import Client, State +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -42,12 +43,17 @@ class RabbitAirDebouncer(Debouncer[Coroutine[Any, Any, None]]): class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): """Class to manage fetching data from single endpoint.""" - def __init__(self, hass: HomeAssistant, device: Client) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: Client + ) -> None: """Initialize global data updater.""" self.device = device super().__init__( hass, _LOGGER, + config_entry=config_entry, name="rabbitair", update_interval=timedelta(seconds=10), request_refresh_debouncer=RabbitAirDebouncer(hass), diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index cfbee0be67c..4c13f3a8b02 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -9,7 +9,7 @@ from rabbitair import Mode, Model, Speed from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -39,7 +39,9 @@ PRESET_MODES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 189a08e998d..3bf0f716c6d 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN as DOMAIN_RACHIO, @@ -46,7 +46,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio binary sensors.""" entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py index 5c7e13c748a..91ad29fac9f 100644 --- a/homeassistant/components/rachio/calendar.py +++ b/homeassistant/components/rachio/calendar.py @@ -12,7 +12,7 @@ from homeassistant.components.calendar import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for Rachio smart hose timer calendar.""" person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index fac93952b35..cc32bd2e56f 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -10,7 +10,6 @@ from rachiopy import Rachio from requests.exceptions import ConnectTimeout import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_MANUAL_RUN_MINS, @@ -92,13 +95,11 @@ class RachioConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 92e7c0ea2ba..25cdeac62f7 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp @@ -102,7 +102,7 @@ START_MULTIPLE_ZONES_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" zone_entities = [] diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 5c225697f98..11a9b6b4dc0 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import fields from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -18,24 +17,13 @@ from .coordinator import ( HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, QueueDataUpdateCoordinator, + RadarrConfigEntry, + RadarrData, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] -type RadarrConfigEntry = ConfigEntry[RadarrData] - - -@dataclass(kw_only=True, slots=True) -class RadarrData: - """Radarr data type.""" - - calendar: CalendarUpdateCoordinator - disk_space: DiskSpaceDataUpdateCoordinator - health: HealthDataUpdateCoordinator - movie: MoviesDataUpdateCoordinator - queue: QueueDataUpdateCoordinator - status: StatusDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bool: @@ -50,12 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bo session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) data = RadarrData( - calendar=CalendarUpdateCoordinator(hass, host_configuration, radarr), - disk_space=DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), - health=HealthDataUpdateCoordinator(hass, host_configuration, radarr), - movie=MoviesDataUpdateCoordinator(hass, host_configuration, radarr), - queue=QueueDataUpdateCoordinator(hass, host_configuration, radarr), - status=StatusDataUpdateCoordinator(hass, host_configuration, radarr), + calendar=CalendarUpdateCoordinator(hass, entry, host_configuration, radarr), + disk_space=DiskSpaceDataUpdateCoordinator( + hass, entry, host_configuration, radarr + ), + health=HealthDataUpdateCoordinator(hass, entry, host_configuration, radarr), + movie=MoviesDataUpdateCoordinator(hass, entry, host_configuration, radarr), + queue=QueueDataUpdateCoordinator(hass, entry, host_configuration, radarr), + status=StatusDataUpdateCoordinator(hass, entry, host_configuration, radarr), ) for field in fields(data): coordinator: RadarrDataUpdateCoordinator = getattr(data, field.name) diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 953c7dead18..f09e6015b53 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -11,10 +11,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RadarrConfigEntry from .const import HEALTH_ISSUES +from .coordinator import RadarrConfigEntry from .entity import RadarrEntity BINARY_SENSOR_TYPE = BinarySensorEntityDescription( @@ -28,7 +28,7 @@ BINARY_SENSOR_TYPE = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RadarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" coordinator = entry.runtime_data.health diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index c741c178862..00df27f21bd 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -7,10 +7,9 @@ from datetime import datetime from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RadarrConfigEntry -from .coordinator import CalendarUpdateCoordinator, RadarrEvent +from .coordinator import CalendarUpdateCoordinator, RadarrConfigEntry, RadarrEvent from .entity import RadarrEntity CALENDAR_TYPE = EntityDescription( @@ -22,7 +21,7 @@ CALENDAR_TYPE = EntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RadarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Radarr calendar entity.""" coordinator = entry.runtime_data.calendar diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 6e8a3d55d3e..d343675d7ea 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass from datetime import date, datetime, timedelta -from typing import TYPE_CHECKING, Generic, TypeVar, cast +from typing import Generic, TypeVar, cast from aiopyarr import ( Health, @@ -20,14 +20,27 @@ from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient from homeassistant.components.calendar import CalendarEvent +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -if TYPE_CHECKING: - from . import RadarrConfigEntry + +@dataclass(kw_only=True, slots=True) +class RadarrData: + """Radarr data type.""" + + calendar: CalendarUpdateCoordinator + disk_space: DiskSpaceDataUpdateCoordinator + health: HealthDataUpdateCoordinator + movie: MoviesDataUpdateCoordinator + queue: QueueDataUpdateCoordinator + status: StatusDataUpdateCoordinator + + +type RadarrConfigEntry = ConfigEntry[RadarrData] T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) @@ -53,6 +66,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): def __init__( self, hass: HomeAssistant, + config_entry: RadarrConfigEntry, host_configuration: PyArrHostConfiguration, api_client: RadarrClient, ) -> None: @@ -60,6 +74,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=self._update_interval, ) @@ -140,11 +155,12 @@ class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, + config_entry: RadarrConfigEntry, host_configuration: PyArrHostConfiguration, api_client: RadarrClient, ) -> None: """Initialize.""" - super().__init__(hass, host_configuration, api_client) + super().__init__(hass, config_entry, host_configuration, api_client) self.event: RadarrEvent | None = None self._events: list[RadarrEvent] = [] diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index df1a0686e00..a6d29ee9d1d 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -17,10 +17,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RadarrConfigEntry -from .coordinator import RadarrDataUpdateCoordinator, T +from .coordinator import RadarrConfigEntry, RadarrDataUpdateCoordinator, T from .entity import RadarrEntity @@ -82,14 +81,12 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { "movie": RadarrSensorEntityDescription[int]( key="movies", translation_key="movies", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), "queue": RadarrSensorEntityDescription[int]( key="queue", translation_key="queue", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, value_fn=lambda data, _: data, @@ -117,7 +114,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: RadarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" entities: list[RadarrSensor[Any]] = [] diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index ec1baf6ffd8..268d7955c1b 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -43,10 +43,12 @@ }, "sensor": { "movies": { - "name": "Movies" + "name": "Movies", + "unit_of_measurement": "movies" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::unit_of_measurement%]" }, "start_time": { "name": "Start time" diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 7b2eaba52c4..80dbcf44bc9 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] init_coro = async_get_init_data(hass, host) init_data = await _async_call_or_raise_not_ready(init_coro, host) - coordinator = RadioThermUpdateCoordinator(hass, init_data) + coordinator = RadioThermUpdateCoordinator(hass, entry, init_data) await coordinator.async_config_entry_first_refresh() # Only set the time if the thermostat is diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index af52c5fcea3..09ac5b42b60 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -20,7 +20,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .coordinator import RadioThermUpdateCoordinator @@ -93,7 +93,7 @@ def round_temp(temperature): async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate for a radiotherm device.""" coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index e29c4703e08..298421d3964 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -9,11 +9,11 @@ from urllib.error import URLError from radiotherm.validate import RadiothermTstatError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN from .data import RadioThermInitData, async_get_init_data @@ -44,7 +44,7 @@ class RadioThermConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_init_data: RadioThermInitData | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Discover via DHCP.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 06e3554c8d7..7d483426c83 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -8,6 +8,7 @@ from urllib.error import URLError from radiotherm.validate import RadiothermTstatError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,13 +22,21 @@ UPDATE_INTERVAL = timedelta(seconds=15) class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): """DataUpdateCoordinator to gather data for radio thermostats.""" - def __init__(self, hass: HomeAssistant, init_data: RadioThermInitData) -> None: + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + init_data: RadioThermInitData, + ) -> None: """Initialize DataUpdateCoordinator.""" self.init_data = init_data self._description = f"{init_data.name} ({init_data.host})" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"radiotherm {self.init_data.name}", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py index e7b463e3def..2952e1e5817 100644 --- a/homeassistant/components/radiotherm/switch.py +++ b/homeassistant/components/radiotherm/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RadioThermUpdateCoordinator @@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for a radiotherm device.""" coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index d8b71e2df0b..f9cd751a81e 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -101,18 +101,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> data = RainbirdData( controller, model_info, - coordinator=RainbirdUpdateCoordinator( - hass, - name=entry.title, - controller=controller, - unique_id=entry.unique_id, - model_info=model_info, - ), - schedule_coordinator=RainbirdScheduleUpdateCoordinator( - hass, - name=f"{entry.title} Schedule", - controller=controller, - ), + coordinator=RainbirdUpdateCoordinator(hass, entry, controller, model_info), + schedule_coordinator=RainbirdScheduleUpdateCoordinator(hass, entry, controller), ) await data.coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 5722b8852dd..0b27c7e33c4 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RainbirdUpdateCoordinator @@ -27,7 +27,7 @@ RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" coordinator = config_entry.runtime_data.coordinator diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 160fe70c61e..c48ca438146 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -9,7 +9,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation calendar.""" data = config_entry.runtime_data diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 2ccfa0af62a..426df625697 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -58,26 +58,28 @@ def async_create_clientsession() -> aiohttp.ClientSession: class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): """Coordinator for rainbird API calls.""" + config_entry: RainbirdConfigEntry + def __init__( self, hass: HomeAssistant, - name: str, + config_entry: RainbirdConfigEntry, controller: AsyncRainbirdController, - unique_id: str | None, model_info: ModelAndVersion, ) -> None: """Initialize RainbirdUpdateCoordinator.""" super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=UPDATE_INTERVAL, request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=DEBOUNCER_COOLDOWN, immediate=False ), ) self._controller = controller - self._unique_id = unique_id + self._unique_id = config_entry.unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -145,14 +147,15 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): def __init__( self, hass: HomeAssistant, - name: str, + config_entry: RainbirdConfigEntry, controller: AsyncRainbirdController, ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=f"{config_entry.title} Schedule", update_method=self._async_update_data, update_interval=CALENDAR_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index d8081a796b9..7f1dfe74752 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -10,7 +10,7 @@ from homeassistant.components.number import NumberEntity from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RainbirdUpdateCoordinator @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird number platform.""" async_add_entities( diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 4725a33bc9a..9fab1af0a23 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird sensor.""" async_add_entities( diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index f622a1b9b2c..f188350138e 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -32,7 +32,7 @@ SERVICE_SCHEMA_IRRIGATION: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" coordinator = config_entry.runtime_data.coordinator diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index f1eef40f307..0ee12612323 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 2696c192ed6..84621aba99d 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py index 337324d96eb..b45684ac72b 100644 --- a/homeassistant/components/raincloud/entity.py +++ b/homeassistant/components/raincloud/entity.py @@ -45,7 +45,7 @@ class RainCloudEntity(Entity): """Return the name of the sensor.""" return self._name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 1f9d8d7b2c5..8aaec605c04 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 59a11a6b167..babadcba676 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rainforest_eagle/coordinator.py b/homeassistant/components/rainforest_eagle/coordinator.py index 9c714a291ee..11956681638 100644 --- a/homeassistant/components/rainforest_eagle/coordinator.py +++ b/homeassistant/components/rainforest_eagle/coordinator.py @@ -29,13 +29,13 @@ _LOGGER = logging.getLogger(__name__) class EagleDataCoordinator(DataUpdateCoordinator): """Get the latest data from the Eagle device.""" + config_entry: ConfigEntry eagle100_reader: Eagle100Reader | None = None eagle200_meter: aioeagle.ElectricMeter | None = None - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the data object.""" - self.entry = entry - if self.type == TYPE_EAGLE_100: + if config_entry.data[CONF_TYPE] == TYPE_EAGLE_100: self.model = "EAGLE-100" update_method = self._async_update_data_100 else: @@ -45,7 +45,8 @@ class EagleDataCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, - name=entry.data[CONF_CLOUD_ID], + config_entry=config_entry, + name=config_entry.data[CONF_CLOUD_ID], update_interval=timedelta(seconds=30), update_method=update_method, ) @@ -53,17 +54,12 @@ class EagleDataCoordinator(DataUpdateCoordinator): @property def cloud_id(self): """Return the cloud ID.""" - return self.entry.data[CONF_CLOUD_ID] - - @property - def type(self): - """Return entry type.""" - return self.entry.data[CONF_TYPE] + return self.config_entry.data[CONF_CLOUD_ID] @property def hardware_address(self): """Return hardware address of meter.""" - return self.entry.data[CONF_HARDWARE_ADDRESS] + return self.config_entry.data[CONF_HARDWARE_ADDRESS] @property def is_connected(self): @@ -79,8 +75,8 @@ class EagleDataCoordinator(DataUpdateCoordinator): hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(self.hass), self.cloud_id, - self.entry.data[CONF_INSTALL_CODE], - host=self.entry.data[CONF_HOST], + self.config_entry.data[CONF_INSTALL_CODE], + host=self.config_entry.data[CONF_HOST], ) eagle200_meter = aioeagle.ElectricMeter.create_instance( hub, self.hardware_address @@ -115,8 +111,8 @@ class EagleDataCoordinator(DataUpdateCoordinator): if self.eagle100_reader is None: self.eagle100_reader = Eagle100Reader( self.cloud_id, - self.entry.data[CONF_INSTALL_CODE], - self.entry.data[CONF_HOST], + self.config_entry.data[CONF_INSTALL_CODE], + self.config_entry.data[CONF_HOST], ) out = {} diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 8c4c5927998..58427b0e5ba 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -45,7 +45,9 @@ SENSORS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index 72d258dc1d3..f8e3dde446a 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -20,6 +20,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -30,7 +31,7 @@ def _format_id(value: str | int) -> str: return f"{value or 0:04X}" -def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: +def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str: """Generate unique id from usb attributes.""" return ( f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" @@ -98,9 +99,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" device = discovery_info.device dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 49bd11e8880..3a902377c2e 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.7.0"], + "requirements": ["aioraven==0.7.1"], "usb": [ { "vid": "0403", diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 1025e92ef86..3d358322b70 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -80,7 +80,7 @@ DIAGNOSTICS = ( async def async_setup_entry( hass: HomeAssistant, entry: RAVEnConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 4d486c9c6aa..65648b8d44f 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -13,7 +13,7 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError, UnknownAPICallError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, @@ -465,12 +465,7 @@ async def async_unload_entry( ) -> bool: """Unload an RainMachine config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state is ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of RainMachine, deregister any services # defined during integration setup: for service_name in ( diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 4ba9b58d596..610505e2b7f 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RainMachineConfigEntry from .const import DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT @@ -94,7 +94,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine binary sensors based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index 2f68c6a8a9c..e4ed00930dd 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RainMachineConfigEntry from .const import DATA_PROVISION_SETTINGS @@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine buttons based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 0b40d506566..6ce95d7e547 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -9,7 +9,6 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -19,6 +18,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, @@ -66,19 +66,19 @@ class RainMachineFlowHandler(ConfigFlow, domain=DOMAIN): return RainMachineOptionsFlowHandler() async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" return await self.async_step_homekit_zeroconf(discovery_info) async def async_step_homekit_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery via zeroconf.""" ip_address = discovery_info.host diff --git a/homeassistant/components/rainmachine/coordinator.py b/homeassistant/components/rainmachine/coordinator.py index df7972ef31d..de43e5a073f 100644 --- a/homeassistant/components/rainmachine/coordinator.py +++ b/homeassistant/components/rainmachine/coordinator.py @@ -41,6 +41,7 @@ class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): super().__init__( hass, LOGGER, + config_entry=entry, name=name, update_interval=update_interval, update_method=update_method, @@ -49,7 +50,6 @@ class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): self._rebooting = False self._signal_handler_unsubs: list[Callable[[], None]] = [] - self.config_entry = entry self.signal_reboot_completed = SIGNAL_REBOOT_COMPLETED.format( self.config_entry.entry_id ) diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 1d9225a5bb2..5b23a5d79ef 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import RainMachineConfigEntry, RainMachineData @@ -83,7 +83,7 @@ SELECT_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine selects based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 64f9ecf3990..4677a6d8bca 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utc_from_timestamp, utcnow from . import RainMachineConfigEntry, RainMachineData @@ -153,7 +153,7 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine sensors based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index a564d33e777..aad61458e88 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -5,7 +5,7 @@ "user": { "title": "Fill in your information", "data": { - "ip_address": "Hostname or IP Address", + "ip_address": "Hostname or IP address", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } @@ -157,7 +157,7 @@ }, "unpause_watering": { "name": "Unpause all watering", - "description": "Unpauses all paused watering activities.", + "description": "Resumes all paused watering activities.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -167,7 +167,7 @@ }, "push_flow_meter_data": { "name": "Push flow meter data", - "description": "Push flow meter data to the RainMachine device.", + "description": "Sends flow meter data from Home Assistant to the RainMachine device.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -185,7 +185,7 @@ }, "push_weather_data": { "name": "Push weather data", - "description": "Push weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", + "description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -193,7 +193,7 @@ }, "timestamp": { "name": "Timestamp", - "description": "UNIX Timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." + "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { "name": "Min temp", @@ -251,7 +251,7 @@ }, "unrestrict_watering": { "name": "Unrestrict all watering", - "description": "Unrestrict all watering activities.", + "description": "Removes all watering restrictions.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 2a065f18976..9b62b15d196 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ATTR_ID, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import RainMachineConfigEntry, RainMachineData, async_update_programs_and_zones @@ -174,7 +174,7 @@ RESTRICTIONS_SWITCH_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine switches based on a config entry.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index 39156b05cd4..312937184e4 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RainMachineConfigEntry from .const import DATA_MACHINE_FIRMWARE_UPDATE_STATUS @@ -60,7 +60,7 @@ UPDATE_DESCRIPTION = RainMachineUpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Rainmachine update based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index ae9a5886d59..1af85b43486 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -16,8 +16,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DEFAULT_NAME = "Random binary sensor" @@ -44,7 +47,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" async_add_entities( diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index 35b7757580e..406100388e6 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index aad4fcb851c..6ea296c791e 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -21,8 +21,11 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DEFAULT_MAX, DEFAULT_MIN @@ -57,7 +60,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py index fd88cbcb54c..01aeedbd344 100644 --- a/homeassistant/components/rapt_ble/sensor.py +++ b/homeassistant/components/rapt_ble/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -99,7 +99,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the RAPT Pill BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index 37835ecb40a..b9506c3688c 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 5360ce4a7fe..58e1c2e8237 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -49,7 +49,7 @@ BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 2c9c9addcfb..4133082bcf4 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -51,7 +51,7 @@ SENSORS: tuple[RDWSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index 3a76451358e..8145a93a2b7 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -9,7 +9,7 @@ from aiorecollect.client import PickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -35,7 +35,9 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 36658fb5008..69b1772b9fa 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER @@ -39,7 +39,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index a40760c67f4..7cb71e70f65 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER, @@ -149,9 +149,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: commit_interval = conf[CONF_COMMIT_INTERVAL] db_max_retries = conf[CONF_DB_MAX_RETRIES] db_retry_wait = conf[CONF_DB_RETRY_WAIT] - db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format( - hass_config_path=hass.config.path(DEFAULT_DB_FILE) - ) + db_url = conf.get(CONF_DB_URL) or get_default_url(hass) exclude = conf[CONF_EXCLUDE] exclude_event_types: set[EventType[Any] | str] = set( exclude.get(CONF_EVENT_TYPES, []) @@ -200,3 +198,8 @@ async def _async_setup_integration_platform( instance.queue_task(AddRecorderPlatformTask(domain, platform)) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + +def get_default_url(hass: HomeAssistant) -> str: + """Return the default URL.""" + return DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index b73744ef0d1..f203d6ab69a 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -17,7 +17,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util import dt as dt_util -from ...const import SQLITE_MAX_BIND_VARS +from ...const import DEFAULT_MAX_BIND_VARS from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm from ...util import database_job_retry_wrapper, execute @@ -61,7 +61,7 @@ def _find_duplicates( ) .filter(subquery.c.is_duplicate == 1) .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) original_as_dict = {} @@ -125,10 +125,10 @@ def _delete_duplicates_from_table( if not duplicate_ids: break all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .filter(table.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS])) .delete(synchronize_session=False) ) total_deleted_rows += deleted_rows @@ -205,7 +205,7 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: ) .filter(subquery.c.is_duplicate == 1) .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) + .limit(1000 * DEFAULT_MAX_BIND_VARS) ) duplicates = execute(query) statistic_id = None @@ -230,11 +230,11 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: duplicate_ids = _find_statistics_meta_duplicates(session) if not duplicate_ids: break - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + for i in range(0, len(duplicate_ids), DEFAULT_MAX_BIND_VARS): deleted_rows = ( session.query(StatisticsMeta) .filter( - StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + StatisticsMeta.id.in_(duplicate_ids[i : i + DEFAULT_MAX_BIND_VARS]) ) .delete(synchronize_session=False) ) diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py index d47cbe92bd4..eeebe328007 100644 --- a/homeassistant/components/recorder/backup.py +++ b/homeassistant/components/recorder/backup.py @@ -2,7 +2,7 @@ from logging import getLogger -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from .util import async_migration_in_progress, get_instance @@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.info("Backup start notification, locking database for writes") instance = get_instance(hass) + if hass.state is not CoreState.running: + raise HomeAssistantError("Home Assistant is not running") if async_migration_in_progress(hass): raise HomeAssistantError("Database migration in progress") await instance.lock_database() diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 9cbc77b30c0..ce9aa452fae 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -8,7 +8,9 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import recorder as recorder_helper +from . import get_default_url from .util import get_instance @@ -23,30 +25,28 @@ def async_setup(hass: HomeAssistant) -> None: vol.Required("type"): "recorder/info", } ) -@callback -def ws_info( +@websocket_api.async_response +async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return status of the recorder.""" - if instance := get_instance(hass): - backlog = instance.backlog - migration_in_progress = instance.migration_in_progress - migration_is_live = instance.migration_is_live - recording = instance.recording - # We avoid calling is_alive() as it can block waiting - # for the thread state lock which will block the event loop. - is_running = instance.is_running - max_backlog = instance.max_backlog - else: - backlog = None - migration_in_progress = False - migration_is_live = False - recording = False - is_running = False - max_backlog = None + # Wait for db_connected to ensure the recorder instance is created and the + # migration flags are set. + await hass.data[recorder_helper.DATA_RECORDER].db_connected + instance = get_instance(hass) + backlog = instance.backlog + db_in_default_location = instance.db_url == get_default_url(hass) + migration_in_progress = instance.migration_in_progress + migration_is_live = instance.migration_is_live + recording = instance.recording + # We avoid calling is_alive() as it can block waiting + # for the thread state lock which will block the event loop. + is_running = instance.is_running + max_backlog = instance.max_backlog recorder_info = { "backlog": backlog, + "db_in_default_location": db_in_default_location, "max_backlog": max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 409641e54c9..36ff63a0496 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,18 +30,14 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 +# As soon as we have more than 999 ids, split the query as the +# MySQL optimizer handles it poorly and will no longer +# do an index only scan with a group-by +# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 +MAX_IDS_FOR_INDEXED_GROUP_BY = 999 + # The maximum number of rows (events) we purge in one delete statement -# sqlite3 has a limit of 999 until version 3.32.0 -# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc -# We can increase this back to 1000 once most -# have upgraded their sqlite version -SQLITE_MAX_BIND_VARS = 998 - -# The maximum bind vars for sqlite 3.32.0 and above, but -# capped at 4000 to avoid performance issues -SQLITE_MODERN_MAX_BIND_VARS = 4000 - DEFAULT_MAX_BIND_VARS = 4000 DB_WORKER_PREFIX = "DbWorker" @@ -60,6 +56,11 @@ STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 +# https://github.com/home-assistant/core/pull/120779 +# fixed the foreign keys in the states table but it did +# not bump the schema version which means only databases +# created with schema 44 and later do not need the rebuild. INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fee72ce273f..eaf72b74cdc 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,7 +14,7 @@ import threading import time from typing import TYPE_CHECKING, Any, cast -from propcache import cached_property +from propcache.api import cached_property import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select, update from sqlalchemy.engine import Engine @@ -43,15 +43,17 @@ from homeassistant.helpers.event import ( async_track_time_interval, async_track_utc_time_change, ) +from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.event_type import EventType from . import migration, statistics from .const import ( DB_WORKER_PREFIX, + DEFAULT_MAX_BIND_VARS, DOMAIN, KEEPALIVE_TIME, LAST_REPORTED_SCHEMA_VERSION, @@ -61,7 +63,6 @@ from .const import ( MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -183,7 +184,7 @@ class Recorder(threading.Thread): self.db_retry_wait = db_retry_wait self.database_engine: DatabaseEngine | None = None # Database connection is ready, but non-live migration may be in progress - db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected + db_connected: asyncio.Future[bool] = hass.data[DATA_RECORDER].db_connected self.async_db_connected: asyncio.Future[bool] = db_connected # Database is ready to use but live migration may be in progress self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future() @@ -230,12 +231,9 @@ class Recorder(threading.Thread): self._dialect_name: SupportedDialect | None = None self.enabled = True - # For safety we default to the lowest value for max_bind_vars - # of all the DB types (SQLITE_MAX_BIND_VARS). - # # We update the value once we connect to the DB # and determine what is actually supported. - self.max_bind_vars = SQLITE_MAX_BIND_VARS + self.max_bind_vars = DEFAULT_MAX_BIND_VARS @property def backlog(self) -> int: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index cefce9c4e72..d1a2405406e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -47,7 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index dc49ebb9768..4323ad9466b 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -20,7 +20,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import StateAttributes, States from ..filters import Filters diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 2d8f4da5f38..566e30713f0 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby from operator import itemgetter -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import ( CompoundSelect, Select, + StatementLambdaElement, Subquery, and_, func, @@ -25,9 +26,10 @@ from sqlalchemy.orm.session import Session from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all -from ..const import LAST_REPORTED_SCHEMA_VERSION +from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY from ..db_schema import ( SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, @@ -149,6 +151,7 @@ def _significant_states_stmt( no_attributes: bool, include_start_time_state: bool, run_start_ts: float | None, + slow_dependent_subquery: bool, ) -> Select | CompoundSelect: """Query the database for significant state changes.""" include_last_changed = not significant_changes_only @@ -187,6 +190,7 @@ def _significant_states_stmt( metadata_ids, no_attributes, include_last_changed, + slow_dependent_subquery, ).subquery(), no_attributes, include_last_changed, @@ -257,7 +261,68 @@ def get_significant_states_with_session( start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None - stmt = lambda_stmt( + rows: list[Row] = [] + if TYPE_CHECKING: + assert instance.database_engine is not None + slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery + if include_start_time_state and slow_dependent_subquery: + # https://github.com/home-assistant/core/issues/137178 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY) + else: + iter_metadata_ids = (metadata_ids,) + for metadata_ids_chunk in iter_metadata_ids: + stmt = _generate_significant_states_with_session_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids_chunk, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + oldest_ts, + slow_dependent_subquery, + ) + row_chunk = cast( + list[Row], + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + ) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return _sorted_states_to_dict( + rows, + start_time_ts if include_start_time_state else None, + entity_ids, + entity_id_to_metadata_id, + minimal_response, + compressed_state_format, + no_attributes=no_attributes, + ) + + +def _generate_significant_states_with_session_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], + metadata_ids_in_significant_domains: list[int], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + oldest_ts: float | None, + slow_dependent_subquery: bool, +) -> StatementLambdaElement: + return lambda_stmt( lambda: _significant_states_stmt( start_time_ts, end_time_ts, @@ -268,6 +333,7 @@ def get_significant_states_with_session( no_attributes, include_start_time_state, oldest_ts, + slow_dependent_subquery, ), track_on=[ bool(single_metadata_id), @@ -276,17 +342,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, + slow_dependent_subquery, ], ) - return _sorted_states_to_dict( - execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), - start_time_ts if include_start_time_state else None, - entity_ids, - entity_id_to_metadata_id, - minimal_response, - compressed_state_format, - no_attributes=no_attributes, - ) def get_full_significant_states_with_session( @@ -554,13 +612,14 @@ def get_last_state_changes( ) -def _get_start_time_state_for_entities_stmt( +def _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" + # Engine has a fast dependent subquery optimizer # This query is the result of significant research in # https://github.com/home-assistant/core/issues/132865 # A reverse index scan with a limit 1 is the fastest way to get the @@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt( # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed, False + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, ) .select_from(StatesMeta) .join( @@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt( ) +def _get_start_time_state_for_entities_stmt_group_by( + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + most_recent_states_for_entities_by_date = ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() + ) + stmt = ( + _stmt_and_join_attributes_for_start_state( + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, + ) + .join( + most_recent_states_for_entities_by_date, + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: @@ -620,6 +730,7 @@ def _get_start_time_state_stmt( metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, + slow_dependent_subquery: bool, ) -> Select: """Return the states at a specific point in time.""" if single_metadata_id: @@ -634,7 +745,15 @@ def _get_start_time_state_stmt( ) # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - return _get_start_time_state_for_entities_stmt( + if slow_dependent_subquery: + return _get_start_time_state_for_entities_stmt_group_by( + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + return _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time, metadata_ids, no_attributes, @@ -766,7 +885,7 @@ def _sorted_states_to_dict( attr_cache, start_time_ts, entity_id, - prev_state, # type: ignore[arg-type] + prev_state, first_state[last_updated_ts_idx], no_attributes, ) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 93ffb12d18c..40513c8ea24 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,8 +7,8 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.36", - "fnv-hash-fast==1.0.2", + "SQLAlchemy==2.0.38", + "fnv-hash-fast==1.2.6", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 6ae1e265901..3aa12f2b1f9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -52,6 +52,7 @@ from .auto_repairs.statistics.schema import ( from .const import ( CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, EVENT_TYPE_IDS_SCHEMA_VERSION, + LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION, LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, STATES_META_SCHEMA_VERSION, SupportedDialect, @@ -2073,10 +2074,7 @@ def _wipe_old_string_time_columns( session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( - text( - "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 100000;" - ) + text("UPDATE states set last_updated=NULL, last_changed=NULL LIMIT 100000;") ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -2150,7 +2148,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2181,7 +2179,7 @@ def _migrate_columns_to_timestamp( ) ) result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2280,7 +2278,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2302,7 +2300,7 @@ def _migrate_statistics_columns_to_timestamp( # updated all rows in the table until the rowcount is 0 for table in STATISTICS_TABLES: result = None - while result is None or result.rowcount > 0: # type: ignore[unreachable] + while result is None or result.rowcount > 0: with session_scope(session=session_maker()) as session: result = session.connection().execute( text( @@ -2493,9 +2491,10 @@ class BaseMigration(ABC): if self.initial_schema_version > self.max_initial_schema_version: _LOGGER.debug( "Data migration '%s' not needed, database created with version %s " - "after migrator was added", + "after migrator was added in version %s", self.migration_id, self.initial_schema_version, + self.max_initial_schema_version, ) return False if self.start_schema_version < self.required_schema_version: @@ -2755,9 +2754,9 @@ class EventTypeIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): for db_event_type in missing_db_event_types: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_event_type.event_type is not None - ), "event_type should never be None" + assert db_event_type.event_type is not None, ( + "event_type should never be None" + ) event_type_to_id[db_event_type.event_type] = ( db_event_type.event_type_id ) @@ -2833,9 +2832,9 @@ class EntityIDMigration(BaseMigrationWithQuery, BaseOffLineMigration): for db_states_metadata in missing_states_metadata: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert ( - db_states_metadata.entity_id is not None - ), "entity_id should never be None" + assert db_states_metadata.entity_id is not None, ( + "entity_id should never be None" + ) entity_id_to_metadata_id[db_states_metadata.entity_id] = ( db_states_metadata.metadata_id ) @@ -2871,7 +2870,14 @@ class EventIDPostMigration(BaseRunTimeMigration): """Migration to remove old event_id index from states.""" migration_id = "event_id_post_migration" - max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1 + # Note we don't subtract 1 from the max_initial_schema_version + # in this case because we need to run this migration on databases + # version >= 43 because the schema was not bumped when the table + # rebuild was added in + # https://github.com/home-assistant/core/pull/120779 + # which means its only safe to assume version 44 and later + # do not need the table rebuild + max_initial_schema_version = LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION task = MigrationTask migration_version = 2 diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index b86fd299793..2a4924edab3 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -37,3 +37,13 @@ class DatabaseOptimizer: # https://wiki.postgresql.org/wiki/Loose_indexscan # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool + + # MySQL 8.x+ can end up with a file-sort on a dependent subquery + # which makes the query painfully slow. + # https://github.com/home-assistant/core/issues/137178 + # The solution is to use multiple indexed group-by queries instead + # of the subquery as long as the group by does not exceed + # 999 elements since as soon as we hit 1000 elements MySQL + # will no longer use the group_index_range optimization. + # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 + slow_dependent_subquery: bool diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index a469aa49ab2..11ea9141fc0 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -14,7 +14,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source from .time import process_timestamp @@ -24,12 +24,12 @@ class LegacyLazyState(State): """A lazy version of core State after schema 31.""" __slots__ = [ - "_row", "_attributes", - "_last_changed_ts", - "_last_updated_ts", - "_last_reported_ts", "_context", + "_last_changed_ts", + "_last_reported_ts", + "_last_updated_ts", + "_row", "attr_cache", ] diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index fbf73e75025..919ee078a99 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import TYPE_CHECKING, Any -from propcache import cached_property +from propcache.api import cached_property from sqlalchemy.engine.row import Row from homeassistant.const import ( @@ -16,7 +16,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source @@ -58,8 +58,8 @@ class LazyState(State): self.attr_cache = attr_cache self.context = EMPTY_CONTEXT - @cached_property # type: ignore[override] - def attributes(self) -> dict[str, Any]: + @cached_property + def attributes(self) -> dict[str, Any]: # type: ignore[override] """State attributes.""" return decode_attributes_from_source( getattr(self._row, "attributes", None), self.attr_cache diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 33218000faa..91acad1500e 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import overload -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index fc2a8ccb1cc..30e277d7c0a 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -47,9 +47,9 @@ class RecorderPool(SingletonThreadPool, NullPool): ) -> None: """Create the pool.""" kw["pool_size"] = POOL_SIZE - assert ( - recorder_and_worker_thread_ids is not None - ), "recorder_and_worker_thread_ids is required" + assert recorder_and_worker_thread_ids is not None, ( + "recorder_and_worker_thread_ids is required" + ) self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids SingletonThreadPool.__init__(self, creator, **kw) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 2be02fe8091..cc74d7a2376 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,13 +9,13 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.service import ( async_extract_entity_ids, async_register_admin_service, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN from .core import Recorder diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c6783a5cbc2..97fe73c54fe 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -24,9 +24,11 @@ import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -38,6 +40,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -57,6 +60,7 @@ from .const import ( INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + MAX_IDS_FOR_INDEXED_GROUP_BY, SupportedDialect, ) from .db_schema import ( @@ -147,6 +151,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { for unit in ElectricPotentialConverter.VALID_UNITS }, **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, + **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS}, **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, @@ -559,7 +564,9 @@ def _compile_statistics( platform_stats: list[StatisticResult] = [] current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} # Collect statistics from all platforms implementing support - for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): + for domain, platform in instance.hass.data[ + DATA_RECORDER + ].recorder_platforms.items(): if not ( platform_compile_statistics := getattr( platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None @@ -597,7 +604,7 @@ def _compile_statistics( if start.minute == 50: # Once every hour, update issues - for platform in instance.hass.data[DOMAIN].recorder_platforms.values(): + for platform in instance.hass.data[DATA_RECORDER].recorder_platforms.values(): if not ( platform_update_issues := getattr( platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None @@ -880,7 +887,7 @@ def list_statistic_ids( # the integrations for the missing ones. # # Query all integrations with a registered recorder platform - for platform in hass.data[DOMAIN].recorder_platforms.values(): + for platform in hass.data[DATA_RECORDER].recorder_platforms.values(): if not ( platform_list_statistic_ids := getattr( platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None @@ -968,12 +975,10 @@ def _reduce_statistics( return result -def reduce_day_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_day_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same day and day start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1017,12 +1022,10 @@ def _reduce_statistics_per_day( ) -def reduce_week_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_week_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same week and week start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1075,12 +1078,10 @@ def _find_month_end_time(timestamp: datetime) -> datetime: ) -def reduce_month_ts_factory() -> ( - tuple[ - Callable[[float, float], bool], - Callable[[float], tuple[float, float]], - ] -): +def reduce_month_ts_factory() -> tuple[ + Callable[[float, float], bool], + Callable[[float], tuple[float, float]], +]: """Return functions to match same month and month start end.""" _lower_bound: float = 0 _upper_bound: float = 0 @@ -1670,6 +1671,7 @@ def _augment_result_with_change( drop_sum = "sum" not in _types prev_sums = {} if tmp := _statistics_at_time( + get_instance(hass), session, {metadata[statistic_id][0] for statistic_id in result}, table, @@ -2028,7 +2030,39 @@ def get_latest_short_term_statistics_with_session( ) -def _generate_statistics_at_time_stmt( +def _generate_statistics_at_time_stmt_group_by( + 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.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + return _generate_select_columns_for_types_stmt(table, types) + ( + lambda q: q.join( + most_recent_statistic_ids := ( + select( + 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() + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), + ) + ) + + +def _generate_statistics_at_time_stmt_dependent_sub_query( table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, @@ -2042,8 +2076,7 @@ def _generate_statistics_at_time_stmt( # databases. Since all databases support this query as a join # condition we can use it as a subquery to get the last start_time_ts # before a specific point in time for all entities. - stmt = _generate_select_columns_for_types_stmt(table, types) - stmt += ( + return _generate_select_columns_for_types_stmt(table, types) + ( lambda q: q.select_from(StatisticsMeta) .join( table, @@ -2065,10 +2098,10 @@ def _generate_statistics_at_time_stmt( ) .where(table.metadata_id.in_(metadata_ids)) ) - return stmt def _statistics_at_time( + instance: Recorder, session: Session, metadata_ids: set[int], table: type[StatisticsBase], @@ -2077,8 +2110,41 @@ def _statistics_at_time( ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + if TYPE_CHECKING: + assert instance.database_engine is not None + if not instance.database_engine.optimizer.slow_dependent_subquery: + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + table=table, + metadata_ids=metadata_ids, + start_time_ts=start_time_ts, + types=types, + ) + return cast(list[Row], execute_stmt_lambda_element(session, stmt)) + rows: list[Row] = [] + # https://github.com/home-assistant/core/issues/132865 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + for metadata_ids_chunk in chunked_or_all( + metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY + ): + stmt = _generate_statistics_at_time_stmt_group_by( + table=table, + metadata_ids=metadata_ids_chunk, + start_time_ts=start_time_ts, + types=types, + ) + row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt)) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return rows def _build_sum_converted_stats( @@ -2236,7 +2302,7 @@ def _sorted_statistics_to_dict( def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]: """Validate statistics.""" platform_validation: dict[str, list[ValidationIssue]] = {} - for platform in hass.data[DOMAIN].recorder_platforms.values(): + for platform in hass.data[DATA_RECORDER].recorder_platforms.values(): if platform_validate_statistics := getattr( platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None ): @@ -2247,7 +2313,7 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] def update_statistics_issues(hass: HomeAssistant) -> None: """Update statistics issues.""" with session_scope(hass=hass, read_only=True) as session: - for platform in hass.data[DOMAIN].recorder_platforms.values(): + for platform in hass.data[DATA_RECORDER].recorder_platforms.values(): if platform_update_statistics_issues := getattr( platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None ): diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 2ded6be58d6..43c2ecdc14f 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -16,10 +16,6 @@ "backup_failed_out_of_resources": { "title": "Database backup failed due to lack of resources", "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter." - }, - "sqlite_too_old": { - "title": "Update SQLite to {min_version} or later to continue using the recorder", - "description": "Support for version {server_version} of SQLite is ending; the minimum supported version is {min_version}. Please upgrade your database software." } }, "services": { diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 16feaa19886..6923b792b8b 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -40,7 +40,7 @@ def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) and (db_bytes := get_size(session, database_name)) ): - db_stats["estimated_db_size"] = f"{db_bytes/1024/1024:.2f} MiB" + db_stats["estimated_db_size"] = f"{db_bytes / 1024 / 1024:.2f} MiB" return db_stats diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index 4ca0aa18b88..191fa44c194 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -6,7 +6,7 @@ from datetime import datetime from sqlalchemy.orm.session import Session -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import RecorderRuns diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index fa10c12aa68..4eb9547ee9d 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -11,11 +11,11 @@ import logging import threading from typing import TYPE_CHECKING, Any +from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.typing import UndefinedType from homeassistant.util.event_type import EventType from . import entity_registry, purge, statistics -from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups, session_scope @@ -308,7 +308,7 @@ class AddRecorderPlatformTask(RecorderTask): hass = instance.hass domain = self.domain platform = self.platform - platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms + platforms: dict[str, Any] = hass.data[DATA_RECORDER].recorder_platforms platforms[domain] = platform diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4cf24eb79c5..0acaf0aa68f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -34,16 +34,9 @@ from homeassistant.helpers.recorder import ( # noqa: F401 get_instance, session_scope, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from .const import ( - DEFAULT_MAX_BIND_VARS, - DOMAIN, - SQLITE_MAX_BIND_VARS, - SQLITE_MODERN_MAX_BIND_VARS, - SQLITE_URL_PREFIX, - SupportedDialect, -) +from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -95,9 +88,7 @@ RECOMMENDED_MIN_VERSION_MARIA_DB_108 = _simple_version("10.8.4") MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") -MIN_VERSION_SQLITE = _simple_version("3.31.0") -UPCOMING_MIN_VERSION_SQLITE = _simple_version("3.40.1") -MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0") +MIN_VERSION_SQLITE = _simple_version("3.40.1") # This is the maximum time after the recorder ends the session @@ -376,37 +367,6 @@ def _raise_if_version_unsupported( raise UnsupportedDialect -@callback -def _async_delete_issue_deprecated_version( - hass: HomeAssistant, dialect_name: str -) -> None: - """Delete the issue about upcoming unsupported database version.""" - ir.async_delete_issue(hass, DOMAIN, f"{dialect_name}_too_old") - - -@callback -def _async_create_issue_deprecated_version( - hass: HomeAssistant, - server_version: AwesomeVersion, - dialect_name: str, - min_version: AwesomeVersion, -) -> None: - """Warn about upcoming unsupported database version.""" - ir.async_create_issue( - hass, - DOMAIN, - f"{dialect_name}_too_old", - is_fixable=False, - severity=ir.IssueSeverity.CRITICAL, - translation_key=f"{dialect_name}_too_old", - translation_placeholders={ - "server_version": str(server_version), - "min_version": str(min_version), - }, - breaks_in_ha_version="2025.2.0", - ) - - def _extract_version_from_server_response_or_raise( server_response: str, ) -> AwesomeVersion: @@ -504,8 +464,8 @@ def setup_connection_for_dialect( """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None slow_range_in_select = False + slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: - max_bind_vars = SQLITE_MAX_BIND_VARS if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] dbapi_connection.isolation_level = None # type: ignore[attr-defined] @@ -523,23 +483,6 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) - # No elif here since _raise_if_version_unsupported raises - if version < UPCOMING_MIN_VERSION_SQLITE: - instance.hass.add_job( - _async_create_issue_deprecated_version, - instance.hass, - version or version_string, - dialect_name, - UPCOMING_MIN_VERSION_SQLITE, - ) - else: - instance.hass.add_job( - _async_delete_issue_deprecated_version, instance.hass, dialect_name - ) - - if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: - max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS - # The upper bound on the cache size is approximately 16MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384") @@ -558,15 +501,13 @@ def setup_connection_for_dialect( execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") elif dialect_name == SupportedDialect.MYSQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") if first_connection: result = query_on_connection(dbapi_connection, "SELECT VERSION()") version_string = result[0][0] version = _extract_version_from_server_response(version_string) - is_maria_db = "mariadb" in version_string.lower() - if is_maria_db: + if "mariadb" in version_string.lower(): if not version or version < MIN_VERSION_MARIA_DB: _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB @@ -582,24 +523,25 @@ def setup_connection_for_dialect( instance.hass, version, ) - + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif not version or version < MIN_VERSION_MYSQL: _raise_if_version_unsupported( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + else: + # MySQL + # https://github.com/home-assistant/core/issues/137178 + slow_dependent_subquery = True # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: - max_bind_vars = DEFAULT_MAX_BIND_VARS # PostgreSQL does not support a skip/loose index scan so its # also slow for large distinct queries: # https://wiki.postgresql.org/wiki/Loose_indexscan @@ -625,8 +567,11 @@ def setup_connection_for_dialect( return DatabaseEngine( dialect=SupportedDialect(dialect_name), version=version, - optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), - max_bind_vars=max_bind_vars, + optimizer=DatabaseOptimizer( + slow_range_in_select=slow_range_in_select, + slow_dependent_subquery=slow_dependent_subquery, + ), + max_bind_vars=DEFAULT_MAX_BIND_VARS, ) @@ -987,10 +932,7 @@ def filter_unique_constraint_integrity_error( if ignore: _LOGGER.warning( - ( - "Blocked attempt to insert duplicated %s rows, please report" - " at %s" - ), + "Blocked attempt to insert duplicated %s rows, please report at %s", row_type, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", exc_info=err, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ee5c5dd6d75..d23ecab3dac 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -25,6 +25,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -67,6 +68,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS), vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), @@ -295,13 +297,13 @@ async def ws_list_statistic_ids( async def ws_validate_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Fetch a list of available statistic_id.""" + """Validate statistics and return issues found.""" instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( + validation_issues = await instance.async_add_executor_job( validate_statistics, hass, ) - connection.send_result(msg["id"], statistic_ids) + connection.send_result(msg["id"], validation_issues) @websocket_api.websocket_command( diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index 78fc0a805f6..f5b566ce59d 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 35962ac091b..564cc6c3c06 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index 0f0c852b043..eb2085efda4 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Refoss from a config entry.""" hass.data.setdefault(DOMAIN, {}) discover = await refoss_discovery_server(hass) - refoss_discovery = DiscoveryService(hass, discover) + refoss_discovery = DiscoveryService(hass, entry, discover) hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py index 11e92620fbb..a3ba9ea663d 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -6,6 +6,7 @@ from refoss_ha.device import DeviceInfo from refoss_ha.device_manager import async_build_base_device from refoss_ha.discovery import Discovery, Listener +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -16,9 +17,12 @@ from .coordinator import RefossDataUpdateCoordinator class DiscoveryService(Listener): """Discovery event handler for refoss devices.""" - def __init__(self, hass: HomeAssistant, discovery: Discovery) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, discovery: Discovery + ) -> None: """Init discovery service.""" self.hass = hass + self.config_entry = config_entry self.discovery = discovery self.discovery.add_listener(self) @@ -32,7 +36,7 @@ class DiscoveryService(Listener): if device is None: return - coordo = RefossDataUpdateCoordinator(self.hass, device) + coordo = RefossDataUpdateCoordinator(self.hass, self.config_entry, device) self.hass.data[DOMAIN][COORDINATORS].append(coordo) await coordo.async_refresh() diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py index 929d1b3962b..381f64614b5 100644 --- a/homeassistant/components/refoss/coordinator.py +++ b/homeassistant/components/refoss/coordinator.py @@ -7,6 +7,7 @@ from datetime import timedelta from refoss_ha.controller.device import BaseDevice from refoss_ha.exceptions import DeviceTimeoutError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,11 +17,16 @@ from .const import _LOGGER, DOMAIN, MAX_ERRORS class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): """Manages polling for state changes from the device.""" - def __init__(self, hass: HomeAssistant, device: BaseDevice) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: BaseDevice + ) -> None: """Initialize the data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}-{device.device_info.dev_name}", update_interval=timedelta(seconds=15), ) diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 7065470657f..92090a192e8 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .bridge import RefossDataUpdateCoordinator @@ -94,7 +94,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { key="energy", translation_key="this_month_energy", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=2, subkey="mConsume", @@ -104,7 +104,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { key="energy_returned", translation_key="this_month_energy_returned", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=2, subkey="mConsume", @@ -117,7 +117,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index aed132ecc3a..1d465f7f319 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import RefossDataUpdateCoordinator from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN @@ -20,7 +20,7 @@ from .entity import RefossEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 40b27014211..1d9b281e9b7 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index d544c42efe1..df9eec0622f 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,33 +1,25 @@ """Support to interact with Remember The Milk.""" -import json -import logging -import os - from rtmapi import Rtm import voluptuous as vol from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from .const import LOGGER from .entity import RememberTheMilkEntity +from .storage import RememberTheMilkConfiguration # httplib2 is a transitive dependency from RtmAPI. If this dependency is not # set explicitly, the library does not work. -_LOGGER = logging.getLogger(__name__) DOMAIN = "remember_the_milk" -DEFAULT_NAME = DOMAIN CONF_SHARED_SECRET = "shared_secret" -CONF_ID_MAP = "id_map" -CONF_LIST_ID = "list_id" -CONF_TIMESERIES_ID = "timeseries_id" -CONF_TASK_ID = "task_id" RTM_SCHEMA = vol.Schema( { @@ -41,7 +33,6 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA ) -CONFIG_FILE_NAME = ".remember_the_milk.conf" SERVICE_CREATE_TASK = "create_task" SERVICE_COMPLETE_TASK = "complete_task" @@ -54,17 +45,17 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass) + component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: account_name = rtm_config[CONF_NAME] - _LOGGER.debug("Adding Remember the milk account %s", account_name) + LOGGER.debug("Adding Remember the milk account %s", account_name) api_key = rtm_config[CONF_API_KEY] shared_secret = rtm_config[CONF_SHARED_SECRET] token = stored_rtm_config.get_token(account_name) if token: - _LOGGER.debug("found token for account %s", account_name) + LOGGER.debug("found token for account %s", account_name) _create_instance( hass, account_name, @@ -79,13 +70,19 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, account_name, api_key, shared_secret, stored_rtm_config, component ) - _LOGGER.debug("Finished adding all Remember the milk accounts") + LOGGER.debug("Finished adding all Remember the milk accounts") return True def _create_instance( - hass, account_name, api_key, shared_secret, token, stored_rtm_config, component -): + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + token: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: entity = RememberTheMilkEntity( account_name, api_key, shared_secret, token, stored_rtm_config ) @@ -105,26 +102,30 @@ def _create_instance( def _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component -): - request_id = None + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() - _LOGGER.debug("Sent authentication request to server") + LOGGER.debug("Sent authentication request to server") def register_account_callback(fields: list[dict[str, str]]) -> None: """Call for register the configurator.""" api.retrieve_token(frob) token = api.token if api.token is None: - _LOGGER.error("Failed to register, please try again") + LOGGER.error("Failed to register, please try again") configurator.notify_errors( hass, request_id, "Failed to register, please try again." ) return stored_rtm_config.set_token(account_name, token) - _LOGGER.debug("Retrieved new token from server") + LOGGER.debug("Retrieved new token from server") _create_instance( hass, @@ -152,89 +153,3 @@ def _register_new_account( link_url=url, submit_caption="login completed", ) - - -class RememberTheMilkConfiguration: - """Internal configuration data for RememberTheMilk class. - - This class stores the authentication token it get from the backend. - """ - - def __init__(self, hass): - """Create new instance of configuration.""" - self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - if not os.path.isfile(self._config_file_path): - self._config = {} - return - try: - _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - with open(self._config_file_path, encoding="utf8") as config_file: - self._config = json.load(config_file) - except ValueError: - _LOGGER.error( - "Failed to load configuration file, creating a new one: %s", - self._config_file_path, - ) - self._config = {} - - def save_config(self): - """Write the configuration to a file.""" - with open(self._config_file_path, "w", encoding="utf8") as config_file: - json.dump(self._config, config_file) - - def get_token(self, profile_name): - """Get the server token for a profile.""" - if profile_name in self._config: - return self._config[profile_name][CONF_TOKEN] - return None - - def set_token(self, profile_name, token): - """Store a new server token for a profile.""" - self._initialize_profile(profile_name) - self._config[profile_name][CONF_TOKEN] = token - self.save_config() - - def delete_token(self, profile_name): - """Delete a token for a profile. - - Usually called when the token has expired. - """ - self._config.pop(profile_name, None) - self.save_config() - - def _initialize_profile(self, profile_name): - """Initialize the data structures for a profile.""" - if profile_name not in self._config: - self._config[profile_name] = {} - if CONF_ID_MAP not in self._config[profile_name]: - self._config[profile_name][CONF_ID_MAP] = {} - - def get_rtm_id(self, profile_name, hass_id): - """Get the RTM ids for a Home Assistant task ID. - - The id of a RTM tasks consists of the tuple: - list id, timeseries id and the task id. - """ - self._initialize_profile(profile_name) - ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) - if ids is None: - return None - return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] - - def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, rtm_task_id): - """Add/Update the RTM task ID for a Home Assistant task IS.""" - self._initialize_profile(profile_name) - id_tuple = { - CONF_LIST_ID: list_id, - CONF_TIMESERIES_ID: time_series_id, - CONF_TASK_ID: rtm_task_id, - } - self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple - self.save_config() - - def delete_rtm_id(self, profile_name, hass_id): - """Delete a key mapping.""" - self._initialize_profile(profile_name) - if hass_id in self._config[profile_name][CONF_ID_MAP]: - del self._config[profile_name][CONF_ID_MAP][hass_id] - self.save_config() diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py new file mode 100644 index 00000000000..2fccbf3ee52 --- /dev/null +++ b/homeassistant/components/remember_the_milk/const.py @@ -0,0 +1,5 @@ +"""Constants for the Remember The Milk integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 8fa52b6c06c..be69d16f72f 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -1,20 +1,26 @@ """Support to interact with Remember The Milk.""" -import logging - from rtmapi import Rtm, RtmRequestFailedException from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import LOGGER +from .storage import RememberTheMilkConfiguration class RememberTheMilkEntity(Entity): """Representation of an interface to Remember The Milk.""" - def __init__(self, name, api_key, shared_secret, token, rtm_config): + def __init__( + self, + name: str, + api_key: str, + shared_secret: str, + token: str, + rtm_config: RememberTheMilkConfiguration, + ) -> None: """Create new instance of Remember The Milk component.""" self._name = name self._api_key = api_key @@ -22,11 +28,11 @@ class RememberTheMilkEntity(Entity): self._token = token self._rtm_config = rtm_config self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = None + self._token_valid = False self._check_token() - _LOGGER.debug("Instance created for account %s", self._name) + LOGGER.debug("Instance created for account %s", self._name) - def _check_token(self): + def _check_token(self) -> bool: """Check if the API token is still valid. If it is not valid any more, delete it from the configuration. This @@ -34,7 +40,7 @@ class RememberTheMilkEntity(Entity): """ valid = self._rtm_api.token_valid() if not valid: - _LOGGER.error( + LOGGER.error( "Token for account %s is invalid. You need to register again!", self.name, ) @@ -60,20 +66,21 @@ class RememberTheMilkEntity(Entity): result = self._rtm_api.rtm.timelines.create() timeline = result.timeline.value - if hass_id is None or rtm_id is None: + if rtm_id is None: result = self._rtm_api.rtm.tasks.add( timeline=timeline, name=task_name, parse="1" ) - _LOGGER.debug( + LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) - self._rtm_config.set_rtm_id( - self._name, - hass_id, - result.list.id, - result.list.taskseries.id, - result.list.taskseries.task.id, - ) + if hass_id is not None: + self._rtm_config.set_rtm_id( + self._name, + hass_id, + result.list.id, + result.list.taskseries.id, + result.list.taskseries.task.id, + ) else: self._rtm_api.rtm.tasks.setName( name=task_name, @@ -82,14 +89,14 @@ class RememberTheMilkEntity(Entity): task_id=rtm_id[2], timeline=timeline, ) - _LOGGER.debug( + LOGGER.debug( "Updated task with id '%s' in account %s to name %s", hass_id, self.name, task_name, ) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, @@ -100,7 +107,7 @@ class RememberTheMilkEntity(Entity): hass_id = call.data[CONF_ID] rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) if rtm_id is None: - _LOGGER.error( + LOGGER.error( ( "Could not find task with ID %s in account %s. " "So task could not be closed" @@ -119,23 +126,21 @@ class RememberTheMilkEntity(Entity): timeline=timeline, ) self._rtm_config.delete_rtm_id(self._name, hass_id) - _LOGGER.debug( - "Completed task with id %s in account %s", hass_id, self._name - ) + LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if not self._token_valid: return "API token invalid" diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py new file mode 100644 index 00000000000..593abb7da2c --- /dev/null +++ b/homeassistant/components/remember_the_milk/storage.py @@ -0,0 +1,116 @@ +"""Store RTM configuration in Home Assistant storage.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import cast + +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .const import LOGGER + +CONFIG_FILE_NAME = ".remember_the_milk.conf" +CONF_ID_MAP = "id_map" +CONF_LIST_ID = "list_id" +CONF_TASK_ID = "task_id" +CONF_TIMESERIES_ID = "timeseries_id" + + +class RememberTheMilkConfiguration: + """Internal configuration data for Remember The Milk.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + self._config = {} + LOGGER.debug("Loading configuration from file: %s", self._config_file_path) + try: + self._config = json.loads( + Path(self._config_file_path).read_text(encoding="utf8") + ) + except FileNotFoundError: + LOGGER.debug("Missing configuration file: %s", self._config_file_path) + except OSError: + LOGGER.debug( + "Failed to read from configuration file, %s, using empty configuration", + self._config_file_path, + ) + except ValueError: + LOGGER.error( + "Failed to parse configuration file, %s, using empty configuration", + self._config_file_path, + ) + + def _save_config(self) -> None: + """Write the configuration to a file.""" + Path(self._config_file_path).write_text( + json.dumps(self._config), encoding="utf8" + ) + + def get_token(self, profile_name: str) -> str | None: + """Get the server token for a profile.""" + if profile_name in self._config: + return cast(str, self._config[profile_name][CONF_TOKEN]) + return None + + def set_token(self, profile_name: str, token: str) -> None: + """Store a new server token for a profile.""" + self._initialize_profile(profile_name) + self._config[profile_name][CONF_TOKEN] = token + self._save_config() + + def delete_token(self, profile_name: str) -> None: + """Delete a token for a profile. + + Usually called when the token has expired. + """ + self._config.pop(profile_name, None) + self._save_config() + + def _initialize_profile(self, profile_name: str) -> None: + """Initialize the data structures for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = {} + if CONF_ID_MAP not in self._config[profile_name]: + self._config[profile_name][CONF_ID_MAP] = {} + + def get_rtm_id( + self, profile_name: str, hass_id: str + ) -> tuple[str, str, str] | None: + """Get the RTM ids for a Home Assistant task ID. + + The id of a RTM tasks consists of the tuple: + list id, timeseries id and the task id. + """ + self._initialize_profile(profile_name) + ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) + if ids is None: + return None + return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + + def set_rtm_id( + self, + profile_name: str, + hass_id: str, + list_id: str, + time_series_id: str, + rtm_task_id: str, + ) -> None: + """Add/Update the RTM task ID for a Home Assistant task IS.""" + self._initialize_profile(profile_name) + id_tuple = { + CONF_LIST_ID: list_id, + CONF_TIMESERIES_ID: time_series_id, + CONF_TASK_ID: rtm_task_id, + } + self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple + self._save_config() + + def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: + """Delete a key mapping.""" + self._initialize_profile(profile_name) + if hass_id in self._config[profile_name][CONF_ID_MAP]: + del self._config[profile_name][CONF_ID_MAP][hass_id] + self._save_config() diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 36e482f0a29..f7d87fbf021 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -9,7 +9,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index b3a8075c6ba..42e8517c1e8 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index bf31e4bb55a..91b389c5a1e 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index a8fdf324f1c..0aebd3bd835 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry @@ -37,7 +37,7 @@ class RenaultBinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultBinarySensor] = [ diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 6a9f5e05a38..82b811821ea 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RenaultConfigEntry from .entity import RenaultEntity @@ -29,7 +29,7 @@ class RenaultButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultButtonEntity] = [ diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index 89e62867130..a90331730bc 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from renault_api.kamereon.exceptions import ( AccessDeniedException, @@ -18,6 +18,9 @@ from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import RenaultConfigEntry + T = TypeVar("T", bound=KamereonVehicleDataAttributes) # We have potentially 7 coordinators per vehicle @@ -27,11 +30,13 @@ _PARALLEL_SEMAPHORE = asyncio.Semaphore(1) class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): """Handle vehicle communication with Renault servers.""" + config_entry: RenaultConfigEntry update_method: Callable[[], Awaitable[T]] def __init__( self, hass: HomeAssistant, + config_entry: RenaultConfigEntry, logger: logging.Logger, *, name: str, @@ -42,6 +47,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): super().__init__( hass, logger, + config_entry=config_entry, name=name, update_interval=update_interval, update_method=update_method, diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 08a2a698802..c55ddeb2190 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.components.device_tracker import ( TrackerEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription @@ -30,7 +30,7 @@ class RenaultTrackerEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultDeviceTracker] = [ diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 76b197b2aaf..b37390526cf 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -5,13 +5,13 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import TYPE_CHECKING from renault_api.gigya.exceptions import InvalidCredentialsException from renault_api.kamereon.models import KamereonVehiclesLink from renault_api.renault_account import RenaultAccount from renault_api.renault_client import RenaultClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, @@ -24,6 +24,9 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +if TYPE_CHECKING: + from . import RenaultConfigEntry + from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL from .renault_vehicle import RenaultVehicleProxy @@ -52,7 +55,7 @@ class RenaultHub: return True return False - async def async_initialise(self, config_entry: ConfigEntry) -> None: + async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) @@ -86,7 +89,7 @@ class RenaultHub: vehicle_link: KamereonVehiclesLink, renault_account: RenaultAccount, scan_interval: timedelta, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Set up proxy.""" @@ -95,6 +98,7 @@ class RenaultHub: # Generate vehicle proxy vehicle = RenaultVehicleProxy( hass=self._hass, + config_entry=config_entry, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index d8266d75319..1cce0e4459f 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from functools import wraps import logging -from typing import Any, Concatenate, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from renault_api.exceptions import RenaultException from renault_api.kamereon import models @@ -18,6 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +if TYPE_CHECKING: + from . import RenaultConfigEntry + from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -64,12 +67,14 @@ class RenaultVehicleProxy: def __init__( self, hass: HomeAssistant, + config_entry: RenaultConfigEntry, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, ) -> None: """Initialise vehicle proxy.""" self.hass = hass + self.config_entry = config_entry self._vehicle = vehicle self._details = details self._device_info = DeviceInfo( @@ -98,11 +103,10 @@ class RenaultVehicleProxy: self.coordinators = { coord.key: RenaultDataUpdateCoordinator( self.hass, + self.config_entry, LOGGER, - # Name of the data. For logging purposes. name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), - # Polling interval. Will only be polled if there are subscribers. update_interval=self._scan_interval, ) for coord in COORDINATORS diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index cab1d1f4d8a..cddf83bb860 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -9,7 +9,7 @@ from renault_api.kamereon.models import KamereonVehicleBatteryStatusData from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry @@ -32,7 +32,7 @@ class RenaultSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultSelectEntity] = [ diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 7854d70b1c4..7c513c1b9de 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime @@ -60,7 +60,7 @@ class RenaultSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultSensor[Any]] = [ diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index 80fb2363b1e..df65d16b0b8 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -178,9 +177,8 @@ def setup_services(hass: HomeAssistant) -> None: loaded_entries: list[RenaultConfigEntry] = [ entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - and entry.entry_id in device_entry.config_entries + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries ] for entry in loaded_entries: for vin, vehicle in entry.runtime_data.vehicles.items(): diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 7d9cae1bcf1..8649a5c7b47 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -18,7 +18,7 @@ "data_description": { "kamereon_account_id": "The Kamereon account ID associated with your vehicle" }, - "title": "Kamereon Account ID", + "title": "Kamereon account ID", "description": "You have multiple Kamereon accounts associated to this email, please select one" }, "reauth_confirm": { @@ -228,10 +228,10 @@ }, "exceptions": { "invalid_device_id": { - "message": "No device with id {device_id} was found" + "message": "No device with ID {device_id} was found" }, "no_config_entry_for_device": { - "message": "No loaded config entry was found for device with id {device_id}" + "message": "No loaded config entry was found for device with ID {device_id}" } } } diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index d1eebdf0a5f..b88f9bb036a 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Renson from a config entry.""" api = RensonVentilation(entry.data[CONF_HOST]) - coordinator = RensonCoordinator("Renson", hass, api) + coordinator = RensonCoordinator(hass, entry, api) if not await hass.async_add_executor_job(api.connect): raise ConfigEntryNotReady("Cannot connect to Renson device") diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 46f832ed15c..60b4f54b85c 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RensonCoordinator @@ -86,7 +86,7 @@ BINARY_SENSORS: tuple[RensonBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 02278a0d6f6..830e5a03a4a 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -15,7 +15,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonCoordinator, RensonData from .const import DOMAIN @@ -54,7 +54,7 @@ ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson button platform.""" diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py index 8613220eee1..5d0a20e1c29 100644 --- a/homeassistant/components/renson/coordinator.py +++ b/homeassistant/components/renson/coordinator.py @@ -9,30 +9,35 @@ from typing import Any from renson_endura_delta.renson import RensonVentilation +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for Renson.""" + config_entry: ConfigEntry + def __init__( self, - name: str, hass: HomeAssistant, + config_entry: ConfigEntry, api: RensonVentilation, - update_interval=timedelta(seconds=30), ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, # Name of the data. For logging purposes. - name=name, + name=DOMAIN, # Polling interval. Will only be polled if there are subscribers. - update_interval=update_interval, + update_interval=timedelta(seconds=30), ) self.api = api diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 56b3655ef94..474ab640943 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -18,9 +18,8 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -86,7 +85,7 @@ SPEED_RANGE: tuple[float, float] = (1, 4) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson fan platform.""" diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py index fb8ab8fc552..67fde1c56dc 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RensonCoordinator @@ -40,7 +40,7 @@ RENSON_NUMBER_DESCRIPTION = NumberEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson number platform.""" diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 1df62e12312..ce7e71b1c0b 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -43,7 +43,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonData from .const import DOMAIN @@ -272,7 +272,7 @@ class RensonSensor(RensonEntity, SensorEntity): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson sensor platform.""" diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index b756d16ea79..c81086502ad 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -186,46 +186,46 @@ "services": { "set_timer_level": { "name": "Set timer", - "description": "Set the ventilation timer", + "description": "Sets the ventilation timer", "fields": { "timer_level": { "name": "Level", - "description": "Level setting" + "description": "Ventilation level" }, "minutes": { "name": "Time", - "description": "Time of the timer (0 will disable the timer)" + "description": "Duration of the timer (0 will disable the timer)" } } }, "set_breeze": { - "name": "Set breeze", - "description": "Set the breeze function of the ventilation system", + "name": "Set Breeze", + "description": "Sets the Breeze function of the ventilation system", "fields": { "breeze_level": { "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", - "description": "Ventilation level when breeze function is activated" + "description": "Ventilation level when Breeze function is activated" }, "temperature": { "name": "Temperature", - "description": "Temperature when the breeze function should be activated" + "description": "Temperature when the Breeze function should be activated" }, "activate": { "name": "Activate", - "description": "Activate or disable the breeze feature" + "description": "Activate or disable the Breeze feature" } } }, "set_pollution_settings": { "name": "Set pollution settings", - "description": "Set all the pollution settings of the ventilation system", + "description": "Sets all the pollution settings of the ventilation system", "fields": { "day_pollution_level": { - "name": "Day pollution Level", + "name": "Day pollution level", "description": "Ventilation level when pollution is detected in the day" }, "night_pollution_level": { - "name": "Night pollution Level", + "name": "Night pollution level", "description": "Ventilation level when pollution is detected in the night" }, "humidity_control": { @@ -242,11 +242,11 @@ }, "co2_threshold": { "name": "CO2 threshold", - "description": "Sets the CO2 pollution threshold level in ppm" + "description": "The CO2 pollution threshold level in ppm" }, "co2_hysteresis": { "name": "CO2 hysteresis", - "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + "description": "The CO2 pollution threshold hysteresis level in ppm" } } } diff --git a/homeassistant/components/renson/switch.py b/homeassistant/components/renson/switch.py index 2cd44d20a6a..3b73bb3dffe 100644 --- a/homeassistant/components/renson/switch.py +++ b/homeassistant/components/renson/switch.py @@ -11,7 +11,7 @@ from renson_endura_delta.renson import Level, RensonVentilation from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonCoordinator from .const import DOMAIN @@ -68,7 +68,7 @@ class RensonBreezeSwitch(RensonEntity, SwitchEntity): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py index feb47fadf99..0a07fd2ec4f 100644 --- a/homeassistant/components/renson/time.py +++ b/homeassistant/components/renson/time.py @@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonData from .const import DOMAIN @@ -50,7 +50,7 @@ ENTITY_DESCRIPTIONS: tuple[RensonTimeEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson time platform.""" diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index dd791bbaf1a..71ca5428740 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -5,9 +5,14 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import Any from reolink_aio.api import RETRY_ATTEMPTS -from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform @@ -19,14 +24,15 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -61,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -86,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -115,6 +127,8 @@ async def async_setup_entry( await host.stop() raise ConfigEntryAuthFailed(err) from err raise UpdateFailed(str(err)) from err + except LoginPrivacyModeError: + pass # HTTP API is shutdown when privacy mode is active except ReolinkError as err: host.credential_errors = 0 raise UpdateFailed(str(err)) from err @@ -192,6 +206,23 @@ async def async_setup_entry( hass.http.register_view(PlaybackProxyView(hass)) + async def refresh(*args: Any) -> None: + """Request refresh of coordinator.""" + await device_coordinator.async_request_refresh() + host.cancel_refresh_privacy_mode = None + + def async_privacy_mode_change() -> None: + """Request update when privacy mode is turned off.""" + if host.privacy_mode and not host.api.baichuan.privacy_mode(): + # The privacy mode just turned off, give the API 2 seconds to start + if host.cancel_refresh_privacy_mode is None: + host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh) + host.privacy_mode = host.api.baichuan.privacy_mode() + + host.api.baichuan.register_callback( + "privacy_mode_change", async_privacy_mode_change, 623 + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -216,9 +247,21 @@ async def async_unload_entry( await host.stop() + host.api.baichuan.unregister_callback("privacy_mode_change") + if host.cancel_refresh_privacy_mode is not None: + host.cancel_refresh_privacy_mode() + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +async def async_remove_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: + """Handle removal of an entry.""" + store = get_store(hass, config_entry.entry_id) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: @@ -361,7 +404,7 @@ def migrate_entity_ids( if host.api.supported(None, "UID") and not entity.unique_id.startswith( host.unique_id ): - new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}" + new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}" entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) if entity.device_id in ch_device_ids: diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 2191dedc9cf..4e90bfc9eef 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData @@ -125,7 +125,7 @@ BINARY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6b1fcc65a2f..a67b30a394c 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -138,6 +138,7 @@ BUTTON_ENTITIES = ( HOST_BUTTON_ENTITIES = ( ReolinkHostButtonEntityDescription( key="reboot", + always_available=True, device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -150,7 +151,7 @@ HOST_BUTTON_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink button entities.""" reolink_data: ReolinkData = config_entry.runtime_data @@ -218,7 +219,7 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): - """Base button entity class for Reolink IP cameras.""" + """Base button entity class for Reolink hosts.""" entity_description: ReolinkHostButtonEntityDescription diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a597be3ec7a..329ef9028de 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -13,7 +13,7 @@ from homeassistant.components.camera import ( CameraEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -89,7 +89,7 @@ CAMERA_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index c28e076aab4..7943cadef21 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any @@ -11,11 +12,11 @@ from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -34,8 +35,9 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -49,6 +51,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PROTOCOL = "rtsp" DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} +API_STARTUP_TIME = 5 class ReolinkOptionsFlowHandler(OptionsFlow): @@ -101,6 +104,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._username: str = "admin" self._password: str | None = None + self._user_input: dict[str, Any] | None = None + self._disable_privacy: bool = False @staticmethod @callback @@ -142,7 +147,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) @@ -198,6 +203,21 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host = discovery_info.ip return await self.async_step_user() + async def async_step_privacy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask permission to disable privacy mode.""" + if user_input is not None: + self._disable_privacy = True + return await self.async_step_user(self._user_input) + + assert self._user_input is not None + placeholders = {"host": self._user_input[CONF_HOST]} + return self.async_show_form( + step_id="privacy", + description_placeholders=placeholders, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -219,6 +239,10 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS) try: + if self._disable_privacy: + await host.api.baichuan.set_privacy_mode(enable=False) + # give the camera some time to startup the HTTP API server + await asyncio.sleep(API_STARTUP_TIME) await host.async_init() except UserNotAdmin: errors[CONF_USERNAME] = "not_admin" @@ -227,6 +251,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): except PasswordIncompatible: errors[CONF_PASSWORD] = "password_incompatible" placeholders["special_chars"] = ALLOWED_SPECIAL_CHARS + except LoginPrivacyModeError: + self._user_input = user_input + return await self.async_step_privacy() except CredentialsInvalidError: errors[CONF_PASSWORD] = "invalid_auth" except LoginFirmwareError: @@ -260,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( + None, "privacy_mode" + ) mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac41..7bd93337c46 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 693f2ba59a4..1d0e5d919e7 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + chimes: dict[int, dict[str, Any]] = {} + for chime in api.chime_list: + chimes[chime.dev_id] = {} + chimes[chime.dev_id]["channel"] = chime.channel + chimes[chime.dev_id]["name"] = chime.name + chimes[chime.dev_id]["online"] = chime.online + chimes[chime.dev_id]["event_types"] = chime.chime_event_types + return { "model": api.model, "hardware version": api.hardware_version, @@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics( "channels": api.channels, "stream channels": api.stream_channels, "IPC cams": IPC_cam, + "Chimes": chimes, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, "api versions": api.checked_api_versions, "abilities": api.abilities, + "BC_abilities": api.baichuan.abilities, } diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index dc2366e8f56..55ce4ce891e 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -25,6 +25,7 @@ class ReolinkEntityDescription(EntityDescription): cmd_key: str | None = None cmd_id: int | None = None + always_available: bool = False @dataclass(frozen=True, kw_only=True) @@ -69,7 +70,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] super().__init__(coordinator) self._host = reolink_data.host - self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + self._attr_unique_id: str = ( + f"{self._host.unique_id}_{self.entity_description.key}" + ) http_s = "https" if self._host.api.use_https else "http" self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" @@ -90,17 +93,24 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] @property def available(self) -> bool: """Return True if entity is available.""" - return self._host.api.session_active and super().available + if self.entity_description.always_available: + return True + + return ( + self._host.api.session_active + and not self._host.api.baichuan.privacy_mode() + and super().available + ) @callback def _push_callback(self) -> None: """Handle incoming TCP push event.""" self.async_write_ha_state() - def register_callback(self, unique_id: str, cmd_id: int) -> None: + def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( # pragma: no cover - unique_id, self._push_callback, cmd_id + callback_id, self._push_callback, cmd_id ) async def async_added_to_hass(self) -> None: @@ -108,19 +118,25 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id + callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) - if cmd_id is not None and self._attr_unique_id is not None: - self.register_callback(self._attr_unique_id, cmd_id) + if cmd_id is not None: + self.register_callback(callback_id, cmd_id) + # Privacy mode + self.register_callback(f"{callback_id}_623", 623) async def async_will_remove_from_hass(self) -> None: """Entity removed.""" cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id + callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key) - if cmd_id is not None and self._attr_unique_id is not None: - self._host.api.baichuan.unregister_callback(self._attr_unique_id) + if cmd_id is not None: + self._host.api.baichuan.unregister_callback(callback_id) + # Privacy mode + self._host.api.baichuan.unregister_callback(f"{callback_id}_623") await super().async_will_remove_from_hass() @@ -179,10 +195,10 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Return True if entity is available.""" return super().available and self._host.api.camera_online(self._channel) - def register_callback(self, unique_id: str, cmd_id: int) -> None: + def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( - unique_id, self._push_callback, cmd_id, self._channel + callback_id, self._push_callback, cmd_id, self._channel ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 97d888c0323..2f646ba9090 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .util import get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +66,12 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -95,6 +100,7 @@ class ReolinkHost: self.firmware_ch_list: list[int | None] = [] self.starting: bool = True + self.privacy_mode: bool | None = None self.credential_errors: int = 0 self.webhook_id: str | None = None @@ -112,7 +118,9 @@ class ReolinkHost: self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._fast_poll_error: bool = False self._long_poll_task: asyncio.Task | None = None + self._lost_subscription_start: bool = False self._lost_subscription: bool = False + self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None @callback def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: @@ -147,6 +155,14 @@ class ReolinkHost: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: Store[str] | None = None + if self._config_entry_id is not None: + store = get_store(self._hass, self._config_entry_id) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE) and ( + data := await store.async_load() + ): + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -158,6 +174,19 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_save(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -299,7 +328,7 @@ class ReolinkHost: ) # start long polling if ONVIF push failed immediately - if not self._onvif_push_supported: + if not self._onvif_push_supported and not self._api.baichuan.privacy_mode(): _LOGGER.debug( "Camera model %s does not support ONVIF push, using ONVIF long polling instead", self._api.model, @@ -416,6 +445,11 @@ class ReolinkHost: wake = True self.last_wake = time() + if self._api.baichuan.privacy_mode(): + await self._api.baichuan.get_privacy_mode() + if self._api.baichuan.privacy_mode(): + return # API is shutdown, no need to check states + await self._api.get_states(cmd_list=self.update_cmd, wake=wake) async def disconnect(self) -> None: @@ -459,8 +493,8 @@ class ReolinkHost: if initial: raise # make sure the long_poll_task is always created to try again later - if not self._lost_subscription: - self._lost_subscription = True + if not self._lost_subscription_start: + self._lost_subscription_start = True _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, @@ -468,15 +502,15 @@ class ReolinkHost: ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later - if not self._lost_subscription: - self._lost_subscription = True + if not self._lost_subscription_start: + self._lost_subscription_start = True _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, err, ) else: - self._lost_subscription = False + self._lost_subscription_start = False self._long_poll_task = asyncio.create_task(self._async_long_polling()) async def _async_stop_long_polling(self) -> None: @@ -543,6 +577,9 @@ class ReolinkHost: self.unregister_webhook() await self._api.unsubscribe() + if self._api.baichuan.privacy_mode(): + return # API is shutdown, no need to subscribe + try: if self._onvif_push_supported and not self._api.baichuan.events_active: await self._renew(SubType.push) @@ -666,7 +703,9 @@ class ReolinkHost: try: channels = await self._api.pull_point_request() except ReolinkError as ex: - if not self._long_poll_error: + if not self._long_poll_error and self._api.subscribed( + SubType.long_poll + ): _LOGGER.error("Error while requesting ONVIF pull point: %s", ex) await self._api.unsubscribe(sub_type=SubType.long_poll) self._long_poll_error = True diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index a9c231bf68f..26198a11594 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -371,6 +371,12 @@ }, "led": { "default": "mdi:lightning-bolt-circle" + }, + "privacy_mode": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } } } }, diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index bbb9592dd76..d48790264d1 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ( ReolinkChannelCoordinatorEntity, @@ -92,7 +92,7 @@ HOST_LIGHT_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink light entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index bb6b668368b..f923efdbbf2 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.6"] + "requirements": ["reolink-aio==0.12.1"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index e912bfb5100..39514d58cb7 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -18,7 +18,6 @@ from homeassistant.components.media_source import ( Unresolvable, ) from homeassistant.components.stream import create_stream -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -71,7 +70,7 @@ class ReolinkVODMediaSource(MediaSource): host = get_host(self.hass, config_entry_id) def get_vod_type() -> VodRequestType: - if filename.endswith(".mp4"): + if filename.endswith((".mp4", ".vref")): if host.api.is_nvr: return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK @@ -151,9 +150,7 @@ class ReolinkVODMediaSource(MediaSource): entity_reg = er.async_get(self.hass) device_reg = dr.async_get(self.hass) - for config_entry in self.hass.config_entries.async_entries(DOMAIN): - if config_entry.state != ConfigEntryState.LOADED: - continue + for config_entry in self.hass.config_entries.async_loaded_entries(DOMAIN): channels: list[str] = [] host = config_entry.runtime_data.host entities = er.async_entries_for_config_entry( @@ -222,7 +219,7 @@ class ReolinkVODMediaSource(MediaSource): if main_enc == "h265": _LOGGER.debug( "Reolink camera %s uses h265 encoding for main stream," - "playback only possible using sub stream", + "playback at high resolution may not work in all browsers/apps", host.api.camera_name(channel), ) @@ -236,34 +233,29 @@ class ReolinkVODMediaSource(MediaSource): can_play=False, can_expand=True, ), + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="High resolution", + can_play=False, + can_expand=True, + ), ] - if main_enc != "h265": - children.append( - BrowseMediaSource( - domain=DOMAIN, - identifier=f"RES|{config_entry_id}|{channel}|main", - media_class=MediaClass.CHANNEL, - media_content_type=MediaType.PLAYLIST, - title="High resolution", - can_play=False, - can_expand=True, - ), - ) if host.api.supported(channel, "autotrack_stream"): - children.append( - BrowseMediaSource( - domain=DOMAIN, - identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", - media_class=MediaClass.CHANNEL, - media_content_type=MediaType.PLAYLIST, - title="Autotrack low resolution", - can_play=False, - can_expand=True, - ), - ) - if main_enc != "h265": - children.append( + children.extend( + [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Autotrack low resolution", + can_play=False, + can_expand=True, + ), BrowseMediaSource( domain=DOMAIN, identifier=f"RES|{config_entry_id}|{channel}|autotrack_main", @@ -273,11 +265,7 @@ class ReolinkVODMediaSource(MediaSource): can_play=False, can_expand=True, ), - ) - - if len(children) == 1: - return await self._async_generate_camera_days( - config_entry_id, channel, "sub" + ] ) title = host.api.camera_name(channel) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e4b52c85d45..48382df4cbc 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ( ReolinkChannelCoordinatorEntity, @@ -424,6 +424,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_brightness", cmd_key="GetImage", + cmd_id=26, translation_key="image_brightness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -437,6 +438,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_contrast", cmd_key="GetImage", + cmd_id=26, translation_key="image_contrast", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -450,6 +452,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_saturation", cmd_key="GetImage", + cmd_id=26, translation_key="image_saturation", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -463,6 +466,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_sharpness", cmd_key="GetImage", + cmd_id=26, translation_key="image_sharpness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -476,6 +480,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_hue", cmd_key="GetImage", + cmd_id=26, translation_key="image_hue", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -533,7 +538,7 @@ CHIME_NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7a74be2e28c..c0b20da0238 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -23,7 +23,7 @@ from reolink_aio.api import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ( ReolinkChannelCoordinatorEntity, @@ -80,6 +80,7 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="day_night_mode", cmd_key="GetIsp", + cmd_id=26, translation_key="day_night_mode", entity_category=EntityCategory.CONFIG, get_options=[mode.name for mode in DayNightEnum], @@ -294,7 +295,7 @@ CHIME_SELECT_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 36900da99ca..ecad555b481 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .entity import ( @@ -150,7 +150,7 @@ HDD_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index acd31fe0d7d..d170aa32379 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -40,7 +40,7 @@ def async_setup_services(hass: HomeAssistant) -> None: if ( config_entry is None or device is None - or config_entry.state == ConfigEntryState.NOT_LOADED + or config_entry.state != ConfigEntryState.LOADED ): raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 74bb227d078..f5d2de977ae 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -13,7 +13,7 @@ from homeassistant.components.siren import ( SirenEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -40,7 +40,7 @@ SIREN_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink siren entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9b15166361a..335ed92d32e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -18,6 +18,10 @@ "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } + }, + "privacy": { + "title": "Permission to disable Reolink privacy mode", + "description": "Privacy mode is enabled on Reolink device {host}. By pressing SUBMIT, the privacy mode will be disabled to retrieve the necessary information from the Reolink device. You can abort the setup by pressing X and repeat the setup at a time in which privacy mode can be disabled. After this configuration, you are free to enable the privacy mode again using the privacy mode switch entity. During normal startup the privacy mode will not be disabled. Note however that all entities will be marked unavailable as long as the privacy mode is active." } }, "error": { @@ -89,6 +93,9 @@ "timeout": { "message": "Timeout waiting on a response: {err}" }, + "unexpected": { + "message": "Unexpected Reolink error: {err}" + }, "firmware_install_error": { "message": "Error trying to update Reolink firmware: {err}" }, @@ -119,7 +126,7 @@ }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", - "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." + "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." } }, "services": { @@ -734,8 +741,8 @@ "battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", - "charging": "Charging", + "discharging": "[%key:common::state::discharging%]", + "charging": "[%key:common::state::charging%]", "chargecomplete": "Charge complete" } }, @@ -805,6 +812,9 @@ }, "led": { "name": "LED" + }, + "privacy_mode": { + "name": "Privacy mode" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 85c35b5c987..0f106c0f2cc 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ( @@ -206,6 +206,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.pir_reduce_alarm(ch) is True, method=lambda api, ch, value: api.set_pir(ch, reduce_alarm=value), ), + ReolinkSwitchEntityDescription( + key="privacy_mode", + always_available=True, + translation_key="privacy_mode", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "privacy_mode"), + value=lambda api, ch: api.baichuan.privacy_mode(ch), + method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), + ), ) NVR_SWITCH_ENTITIES = ( @@ -321,7 +330,7 @@ DEPRECATED_NVR_SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink switch entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 5a8c7d7dc08..0744d66fb5b 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -75,7 +75,7 @@ HOST_UPDATE_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index f52cb08286c..a5556b66a33 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any from reolink_aio.exceptions import ( ApiError, @@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -from .host import ReolinkHost + +if TYPE_CHECKING: + from .host import ReolinkHost + +STORAGE_VERSION = 1 type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] @@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: return config_entry.runtime_data.host +def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: + """Return the reolink store.""" + return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json") + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: @@ -82,21 +92,18 @@ def get_device_uid_and_ch( ch = int(device_uid[1][5:]) is_chime = True else: - ch = host.api.channel_for_uid(device_uid[1]) + device_uid_part = "_".join(device_uid[1:]) + ch = host.api.channel_for_uid(device_uid_part) return (device_uid, ch, is_chime) -T = TypeVar("T") -P = ParamSpec("P") - - # Decorators -def raise_translated_error( - func: Callable[P, Awaitable[T]], -) -> Callable[P, Coroutine[Any, Any, T]]: +def raise_translated_error[**P, R]( + func: Callable[P, Awaitable[R]], +) -> Callable[P, Coroutine[Any, Any, R]]: """Wrap a reolink-aio function to translate any potential errors.""" - async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> T: + async def decorator_raise_translated_error(*args: P.args, **kwargs: P.kwargs) -> R: """Try a reolink-aio function and translate any potential errors.""" try: return await func(*args, **kwargs) @@ -167,6 +174,10 @@ def raise_translated_error( translation_placeholders={"err": str(err)}, ) from err except ReolinkError as err: - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected", + translation_placeholders={"err": str(err)}, + ) from err return decorator_raise_translated_error diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 8d3fc429ce0..8ee09c9ed3d 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -12,11 +12,11 @@ from .issue_handler import ConfirmRepairFlow, RepairsFlowManager from .models import RepairsFlow __all__ = [ - "ConfirmRepairFlow", "DOMAIN", - "repairs_flow_manager", + "ConfirmRepairFlow", "RepairsFlow", "RepairsFlowManager", + "repairs_flow_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 27ddc62a847..16c92d6cd37 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index c976506d1ba..fa5bd388009 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index 1ca3c55e2b2..ace216e1918 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -31,7 +31,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index f7fd8a36113..62ed2d5c5b2 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -26,7 +26,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index fc6ce8c6749..b95e6dd72b7 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index ee93fde35fa..c6a4206de4a 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -30,10 +30,11 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType +from homeassistant.util.ssl import SSLCipherList DOMAIN = "rest_command" @@ -46,6 +47,7 @@ DEFAULT_VERIFY_SSL = True SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"] CONF_CONTENT_TYPE = "content_type" +CONF_INSECURE_CIPHER = "insecure_cipher" COMMAND_SCHEMA = vol.Schema( { @@ -60,6 +62,7 @@ COMMAND_SCHEMA = vol.Schema( vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), vol.Optional(CONF_CONTENT_TYPE): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean, } ) @@ -91,7 +94,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback def async_register_rest_command(name: str, command_config: dict[str, Any]) -> None: """Create service for rest command.""" - websession = async_get_clientsession(hass, command_config[CONF_VERIFY_SSL]) + websession = async_get_clientsession( + hass, + command_config[CONF_VERIFY_SSL], + ssl_cipher=( + SSLCipherList.INSECURE + if command_config[CONF_INSECURE_CIPHER] + else SSLCipherList.PYTHON_DEFAULT + ), + ) timeout = command_config[CONF_TIMEOUT] method = command_config[CONF_METHOD] @@ -135,6 +146,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if content_type: headers[hdrs.CONTENT_TYPE] = content_type + _LOGGER.debug( + "Calling %s %s with headers: %s and payload: %s", + method, + request_url, + headers, + payload, + ) + try: async with getattr(websession, method)( request_url, diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 7e86854dbce..85195fb1581 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 29046ba7616..43a7c03c67b 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -20,9 +20,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index cc52ea978bd..83eb2915f70 100644 --- a/homeassistant/components/rflink/const.py +++ b/homeassistant/components/rflink/const.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_ALIASES = "aliases" CONF_GROUP_ALIASES = "group_aliases" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 695825cf31b..8b21bc9274d 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index 26153acf7ba..0caec4ea2c3 100644 --- a/homeassistant/components/rflink/entity.py +++ b/homeassistant/components/rflink/entity.py @@ -105,12 +105,12 @@ class RflinkDevice(Entity): return self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Assume device state until first device event sets state.""" return self._state is None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @@ -120,7 +120,7 @@ class RflinkDevice(Entity): self._available = availability self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" await super().async_added_to_hass() # Remove temporary bogus entity_id if added @@ -300,7 +300,7 @@ class RflinkCommand(RflinkDevice): class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): """Rflink entity which can switch on/off (eg: light, switch).""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore RFLink device state (ON/OFF).""" await super().async_added_to_hass() if (old_state := await self.async_get_last_state()) is not None: diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 00117140abb..af8d2c76844 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -101,7 +101,7 @@ def entity_class_for_type(entity_type): entity_device_mapping = { # sends only 'dim' commands not compatible with on/off switches TYPE_DIMMABLE: DimmableRflinkLight, - # sends only 'on/off' commands not advices with dimmers and signal + # sends only 'on/off' commands not advised with dimmers and signal # repetition TYPE_SWITCHABLE: RflinkLight, # sends 'dim' and 'on' command to support both dimmers and on/off diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 89632ac50b3..027c39da70f 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 23b93896878..bbbce2b8e9a 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 316cf44ef0d..a86ad5557b4 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON, STATE_ON from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event as evt from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd from .const import ( @@ -91,7 +91,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 866d9ecb1bb..6ce7d88f9f0 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -209,10 +209,7 @@ class RfxtrxOptionsFlow(OptionsFlow): except ValueError: errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" - try: - off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10) - except ValueError: - errors[CONF_OFF_DELAY] = "invalid_input_off_delay" + off_delay = user_input.get(CONF_OFF_DELAY) if not errors: devices = {} @@ -252,11 +249,11 @@ class RfxtrxOptionsFlow(OptionsFlow): vol.Optional( CONF_OFF_DELAY, description={"suggested_value": device_data[CONF_OFF_DELAY]}, - ): str, + ): int, } else: off_delay_schema = { - vol.Optional(CONF_OFF_DELAY): str, + vol.Optional(CONF_OFF_DELAY): int, } data_schema.update(off_delay_schema) diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 473a0d94056..07443afb38b 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import CoverEntity, CoverEntityFeature, Cove from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry from .const import ( @@ -34,7 +34,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py index 405daa37ec5..c3f61dee026 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DATA_RFXOBJECT, DOMAIN diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py index b5752e366bc..f0cc193023c 100644 --- a/homeassistant/components/rfxtrx/entity.py +++ b/homeassistant/components/rfxtrx/entity.py @@ -46,7 +46,7 @@ class RfxtrxEntity(RestoreEntity): self._attr_device_info = DeviceInfo( identifiers=_get_identifiers_from_device_tuple(device_id), model=device.type_string, - name=f"{device.type_string} {device.id_string}", + name=f"{device.type_string} {device_id.id_string}", ) self._attr_unique_id = "_".join(x for x in device_id) self._device = device @@ -54,7 +54,7 @@ class RfxtrxEntity(RestoreEntity): self._device_id = device_id # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to # group events regardless of their group indices. - (self._group_id, _, _) = cast(str, device.id_string).partition(":") + (self._group_id, _, _) = device_id.id_string.partition(":") async def async_added_to_hass(self) -> None: """Restore RFXtrx device state (ON/OFF).""" diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py index 212d93b5019..40d02953aeb 100644 --- a/homeassistant/components/rfxtrx/event.py +++ b/homeassistant/components/rfxtrx/event.py @@ -11,7 +11,7 @@ from homeassistant.components.event import EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import DeviceTuple, async_setup_platform_entry @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 0e2f7bef65a..90c0d2eeed7 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST @@ -32,7 +32,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 4f8ae9767e2..4b256279445 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import DeviceTuple, async_setup_platform_entry, get_rfx_object @@ -57,7 +57,7 @@ def _rssi_convert(value: int | None) -> str | None: """Rssi is given as dBm value.""" if value is None: return None - return f"{value*8-120}" + return f"{value * 8 - 120}" @dataclass(frozen=True) @@ -241,7 +241,7 @@ SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index 1635f1f55a9..1164dafbfce 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -11,7 +11,7 @@ from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFe from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import DEFAULT_OFF_DELAY, DeviceTuple, async_setup_platform_entry @@ -47,7 +47,7 @@ def get_first_key(data: dict[int, str], entry: str) -> int: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index aeb4b2395d3..db4efad5bb4 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -54,7 +54,7 @@ "data": { "off_delay": "Off delay", "off_delay_enabled": "Enable off delay", - "data_bit": "Number of data bits", + "data_bits": "Number of data bits", "command_on": "Data bits value for command on", "command_off": "Data bits value for command off", "venetian_blind_mode": "Venetian blind mode", @@ -68,7 +68,6 @@ "invalid_event_code": "Invalid event code", "invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_off": "Invalid input for command off", - "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 1464cccb5c4..b3eb63fb2b4 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd from .const import ( @@ -35,14 +35,13 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: isinstance(event.device, rfxtrxmod.LightingDevice) and not event.device.known_to_be_dimmable and not event.device.known_to_be_rollershutter - or isinstance(event.device, rfxtrxmod.RfyDevice) - ) + ) or isinstance(event.device, rfxtrxmod.RfyDevice) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 71e80086833..9c9104258a8 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -17,7 +17,7 @@ PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ridwell from a config entry.""" - coordinator = RidwellDataUpdateCoordinator(hass, name=entry.title) + coordinator = RidwellDataUpdateCoordinator(hass, entry) await coordinator.async_initialize() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py index ecca0366754..bb7982a5391 100644 --- a/homeassistant/components/ridwell/calendar.py +++ b/homeassistant/components/ridwell/calendar.py @@ -9,7 +9,7 @@ from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator @@ -36,7 +36,9 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell calendars based on a config entry.""" coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index 28190522c76..336a71bc67f 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -29,7 +29,7 @@ class RidwellDataUpdateCoordinator( config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, *, name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: @@ -37,7 +37,13 @@ class RidwellDataUpdateCoordinator( self.dashboard_url = "" self.user_id = "" - super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 7fc7fdb5348..30f97ecaea8 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SENSOR_TYPE_NEXT_PICKUP from .coordinator import RidwellDataUpdateCoordinator @@ -34,7 +34,9 @@ SENSOR_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index 04e3e4c5ff9..e3be9ea5368 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator @@ -24,7 +24,9 @@ SWITCH_DESCRIPTION = SwitchEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index edc084fb57b..8e36f3e85e7 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -2,39 +2,29 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any, cast import uuid -from ring_doorbell import Auth, Ring, RingDevices +from ring_doorbell import Auth, Ring from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS -from .coordinator import RingDataCoordinator, RingListenCoordinator +from .coordinator import ( + RingConfigEntry, + RingData, + RingDataCoordinator, + RingListenCoordinator, +) _LOGGER = logging.getLogger(__name__) -@dataclass -class RingData: - """Class to support type hinting of ring data collection.""" - - api: Ring - devices: RingDevices - devices_coordinator: RingDataCoordinator - listen_coordinator: RingListenCoordinator - - -type RingConfigEntry = ConfigEntry[RingData] - - def get_auth_user_agent() -> str: """Return user-agent for Auth instantiation. @@ -71,10 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool ) ring = Ring(auth) - devices_coordinator = RingDataCoordinator(hass, ring) + devices_coordinator = RingDataCoordinator(hass, entry, ring) listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS) listen_coordinator = RingListenCoordinator( - hass, ring, listen_credentials, listen_credentials_updater + hass, entry, ring, listen_credentials, listen_credentials_updater ) await devices_coordinator.async_config_entry_first_refresh() @@ -91,19 +81,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Unload Ring entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: RingConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Migrate old config entry.""" entry_version = entry.version entry_minor_version = entry.minor_version diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 85a916e95cd..49051ee5e11 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_at from . import RingConfigEntry @@ -30,6 +30,9 @@ from .entity import ( async_check_create_deprecated, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RingBinarySensorEntityDescription( @@ -52,7 +55,6 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( ), RingBinarySensorEntityDescription( key=KIND_MOTION, - translation_key=KIND_MOTION, device_class=BinarySensorDeviceClass.MOTION, capability=RingCapability.MOTION_DETECTION, deprecated_info=DeprecatedInfo( @@ -65,7 +67,7 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index b9d5cceb373..09e6c0e413a 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -6,12 +6,16 @@ from ring_doorbell import RingOther from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + BUTTON_DESCRIPTION = ButtonEntityDescription( key="open_door", translation_key="open_door" ) @@ -20,7 +24,7 @@ BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the buttons for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index ccd91c163d6..156d82665d2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -27,13 +27,18 @@ from homeassistant.components.camera import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, exception_wrap +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + FORCE_REFRESH_INTERVAL = timedelta(minutes=3) MOTION_DETECTION_CAPABILITY = "motion_detection" @@ -71,7 +76,7 @@ CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" ring_data = entry.runtime_data @@ -214,8 +219,13 @@ class RingCam(RingEntity[RingDoorBell], Camera): ) -> None: """Handle a WebRTC candidate.""" if candidate.sdp_m_line_index is None: - msg = "The sdp_m_line_index is required for ring webrtc streaming" - raise HomeAssistantError(msg) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sdp_m_line_index_required", + translation_placeholders={ + "device": self._device.name, + }, + ) await self._device.on_webrtc_candidate( session_id, candidate.candidate, candidate.sdp_m_line_index ) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a1024186349..7d5654947d8 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -8,7 +8,6 @@ import uuid from ring_doorbell import Auth, AuthenticationError, Requires2FAError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -24,8 +23,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent from .const import CONF_2FA, CONF_CONFIG_ENTRY_MINOR_VERSION, DOMAIN @@ -78,7 +78,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): hardware_id: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" # Ring has a single config entry per cloud username rather than per device diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index b143fd3dda0..413c48c35eb 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -1,9 +1,12 @@ """Data coordinators for the ring integration.""" +from __future__ import annotations + from asyncio import TaskGroup from collections.abc import Callable, Coroutine +from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any +from typing import Any from ring_doorbell import ( AuthenticationError, @@ -15,7 +18,7 @@ from ring_doorbell import ( ) from ring_doorbell.listen import RingEventListener -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( @@ -24,37 +27,33 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -async def _call_api[*_Ts, _R]( - hass: HomeAssistant, - target: Callable[[*_Ts], Coroutine[Any, Any, _R]], - *args: *_Ts, - msg_suffix: str = "", -) -> _R: - try: - return await target(*args) - except AuthenticationError as err: - # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) - raise ConfigEntryAuthFailed from err - except RingTimeout as err: - raise UpdateFailed( - f"Timeout communicating with API{msg_suffix}: {err}" - ) from err - except RingError as err: - raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err +@dataclass +class RingData: + """Class to support type hinting of ring data collection.""" + + api: Ring + devices: RingDevices + devices_coordinator: RingDataCoordinator + listen_coordinator: RingListenCoordinator + + +type RingConfigEntry = ConfigEntry[RingData] class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): """Base class for device coordinators.""" + config_entry: RingConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: RingConfigEntry, ring_api: Ring, ) -> None: """Initialize my coordinator.""" @@ -63,16 +62,42 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): name="devices", logger=_LOGGER, update_interval=SCAN_INTERVAL, + config_entry=config_entry, ) self.ring_api: Ring = ring_api self.first_call: bool = True + async def _call_api[*_Ts, _R]( + self, + target: Callable[[*_Ts], Coroutine[Any, Any, _R]], + *args: *_Ts, + ) -> _R: + try: + return await target(*args) + except AuthenticationError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication", + ) from err + except RingTimeout as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_timeout", + ) from err + except RingError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + async def _async_update_data(self) -> RingDevices: """Fetch data from API endpoint.""" update_method: str = ( "async_update_data" if self.first_call else "async_update_devices" ) - await _call_api(self.hass, getattr(self.ring_api, update_method)) + await self._call_api(getattr(self.ring_api, update_method)) self.first_call = False devices: RingDevices = self.ring_api.devices() subscribed_device_ids = set(self.async_contexts()) @@ -84,18 +109,14 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): async with TaskGroup() as tg: if device.has_capability("history"): tg.create_task( - _call_api( - self.hass, + self._call_api( lambda device: device.async_history(limit=10), device, - msg_suffix=f" for device {device.name}", # device_id is the mac ) ) tg.create_task( - _call_api( - self.hass, + self._call_api( device.async_update_health_data, - msg_suffix=f" for device {device.name}", ) ) except ExceptionGroup as eg: @@ -107,11 +128,12 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): """Global notifications coordinator.""" - config_entry: config_entries.ConfigEntry + config_entry: RingConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: RingConfigEntry, ring_api: Ring, listen_credentials: dict[str, Any] | None, listen_credentials_updater: Callable[[dict[str, Any]], None], @@ -126,9 +148,6 @@ class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} self._listen_callback_id: int | None = None - config_entry = config_entries.current_entry.get() - if TYPE_CHECKING: - assert config_entry self.config_entry = config_entry self.start_timeout = 10 self.config_entry.async_on_unload(self.async_shutdown) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index b93a7f35322..5d77bf3a285 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -2,7 +2,8 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any, Concatenate, Generic, cast +import logging +from typing import Any, Concatenate, Generic, TypeVar, cast from ring_doorbell import ( AuthenticationError, @@ -11,7 +12,6 @@ from ring_doorbell import ( RingGeneric, RingTimeout, ) -from typing_extensions import TypeVar from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -37,6 +37,8 @@ _RingCoordinatorT = TypeVar( bound=(RingDataCoordinator | RingListenCoordinator), ) +_LOGGER = logging.getLogger(__name__) + @dataclass(slots=True) class DeprecatedInfo: @@ -63,14 +65,22 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( return await async_func(self, *args, **kwargs) except AuthenticationError as err: self.coordinator.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(err) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_authentication", + ) from err except RingTimeout as err: raise HomeAssistantError( - f"Timeout communicating with API {async_func}: {err}" + translation_domain=DOMAIN, + translation_key="api_timeout", ) from err except RingError as err: + _LOGGER.debug( + "Error calling %s in platform %s: ", async_func.__name__, self.platform + ) raise HomeAssistantError( - f"Error communicating with API{async_func}: {err}" + translation_domain=DOMAIN, + translation_key="api_error", ) from err return _wrap diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index 71a4bc8aea5..db99a10de74 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -12,12 +12,15 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RingConfigEntry from .coordinator import RingListenCoordinator from .entity import RingBaseEntity, RingDeviceT +# Event entity does not perform updates or actions. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): @@ -54,7 +57,7 @@ EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up events for a Ring device.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 9e29373a3aa..34915dd5133 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -9,8 +9,8 @@ from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator @@ -18,6 +18,9 @@ from .entity import RingEntity, exception_wrap _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 # It takes a few seconds for the API to correctly return an update indicating # that the changes have been made. Once we request a change (i.e. a light @@ -37,7 +40,7 @@ class OnOffState(StrEnum): async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py index 91aabb6c800..68b41451bd0 100644 --- a/homeassistant/components/ring/number.py +++ b/homeassistant/components/ring/number.py @@ -13,18 +13,22 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity, refresh_after +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a numbers for a Ring device.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index dee67882857..5744ed9a4d8 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RingConfigEntry @@ -41,11 +41,14 @@ from .entity import ( async_check_create_deprecated, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" ring_data = entry.runtime_data @@ -255,7 +258,6 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( ), RingSensorEntityDescription[RingGeneric]( key="wifi_signal_strength", - translation_key="wifi_signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index b1452f7aeb5..7f096c0e643 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -22,7 +22,7 @@ from homeassistant.components.siren import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RingConfigEntry from .coordinator import RingDataCoordinator @@ -36,6 +36,10 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class RingSirenEntityDescription( @@ -81,7 +85,7 @@ SIRENS: tuple[RingSirenEntityDescription[Any], ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 8170ec8e161..2d7e0b17da1 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -6,12 +6,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Ring account username.", + "password": "Your Ring account password." } }, "2fa": { "title": "Two-factor authentication", "data": { "2fa": "Two-factor code" + }, + "data_description": { + "2fa": "Account verification code via the method selected in your Ring account settings." } }, "reauth_confirm": { @@ -19,13 +26,19 @@ "description": "The Ring integration needs to re-authenticate your account {username}", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::ring::config::step::user::data_description::password%]" } }, "reconfigure": { - "title": "Reconfigure Ring Integration", + "title": "Reconfigure Ring integration", "description": "Will create a new Authorized Device for {username} at ring.com", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::ring::config::step::user::data_description::password%]" } } }, @@ -43,9 +56,6 @@ "binary_sensor": { "ding": { "name": "Ding" - }, - "motion": { - "name": "Motion" } }, "event": { @@ -109,9 +119,6 @@ }, "wifi_signal_category": { "name": "Wi-Fi signal category" - }, - "wifi_signal_strength": { - "name": "Wi-Fi signal strength" } }, "switch": { @@ -134,6 +141,20 @@ } } }, + "exceptions": { + "api_authentication": { + "message": "Authentication error communicating with Ring API" + }, + "api_timeout": { + "message": "Timeout communicating with Ring API" + }, + "api_error": { + "message": "Error communicating with Ring API" + }, + "sdp_m_line_index_required": { + "message": "Error negotiating stream for {device}" + } + }, "issues": { "deprecated_entity": { "title": "Detected deprecated {platform} entity usage", diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 0ac31fec209..02d98388edc 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,8 +11,8 @@ from ring_doorbell.const import DOORBELL_EXISTING_TYPE from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator @@ -27,6 +27,10 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# Actions restricted to 1 at a time +PARALLEL_UPDATES = 1 + IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2} @@ -82,7 +86,7 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 72510ea251d..30d2d77dcb4 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 7255c724e3f..56c7a509cca 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -16,7 +16,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PIN, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_TYPE, CONF_USERNAME, Platform, @@ -30,7 +29,6 @@ from .const import ( CONF_CONCURRENCY, DATA_COORDINATOR, DEFAULT_CONCURRENCY, - DEFAULT_SCAN_INTERVAL, DOMAIN, EVENTS_COORDINATOR, SYSTEM_UPDATE_SIGNAL, @@ -144,12 +142,9 @@ async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> b except UnauthorizedError as error: raise ConfigEntryAuthFailed from error - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = RiscoDataUpdateCoordinator(hass, risco, scan_interval) + coordinator = RiscoDataUpdateCoordinator(hass, entry, risco) await coordinator.async_config_entry_first_refresh() - events_coordinator = RiscoEventsDataUpdateCoordinator( - hass, risco, entry.entry_id, 60 - ) + events_coordinator = RiscoEventsDataUpdateCoordinator(hass, entry, risco) entry.async_on_unload(entry.add_update_listener(_update_listener)) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index b1eae8fd917..2472baa932e 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LocalData, is_local from .const import ( @@ -50,7 +50,7 @@ STATES_TO_SUPPORTED_FEATURES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" options = {**DEFAULT_OPTIONS, **config_entry.options} diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index a7ca0129b06..ff61985fef3 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LocalData, is_local from .const import DATA_COORDINATOR, DOMAIN, SYSTEM_UPDATE_SIGNAL @@ -73,7 +73,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" if is_local(config_entry): diff --git a/homeassistant/components/risco/coordinator.py b/homeassistant/components/risco/coordinator.py index 8430b6a6172..e7140eb9616 100644 --- a/homeassistant/components/risco/coordinator.py +++ b/homeassistant/components/risco/coordinator.py @@ -10,11 +10,13 @@ from pyrisco import CannotConnectError, OperationError, RiscoCloud, Unauthorized from pyrisco.cloud.alarm import Alarm from pyrisco.cloud.event import Event +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN LAST_EVENT_STORAGE_VERSION = 1 LAST_EVENT_TIMESTAMP_KEY = "last_event_timestamp" @@ -24,17 +26,26 @@ _LOGGER = logging.getLogger(__name__) class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): """Class to manage fetching risco data.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, risco: RiscoCloud, scan_interval: int + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + risco: RiscoCloud, ) -> None: """Initialize global risco data updater.""" self.risco = risco - interval = timedelta(seconds=scan_interval) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=interval, + update_interval=timedelta( + seconds=config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + ), ) async def _async_update_data(self) -> Alarm: @@ -48,20 +59,27 @@ class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): """Class to manage fetching risco data.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, risco: RiscoCloud, eid: str, scan_interval: int + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + risco: RiscoCloud, ) -> None: """Initialize global risco data updater.""" self.risco = risco self._store = Store[dict[str, Any]]( - hass, LAST_EVENT_STORAGE_VERSION, f"risco_{eid}_last_event_timestamp" + hass, + LAST_EVENT_STORAGE_VERSION, + f"risco_{config_entry.entry_id}_last_event_timestamp", ) - interval = timedelta(seconds=scan_interval) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}_events", - update_interval=interval, + update_interval=timedelta(seconds=60), ) async def _async_update_data(self) -> list[Event]: diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 149b8761589..43d471172d6 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.5"] + "requirements": ["pyrisco==0.6.7"] } diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index c1495512e62..93683f1aa50 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -46,7 +46,7 @@ EVENT_ATTRIBUTES = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if is_local(config_entry): diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index 8bad2c6c15e..547dedd3933 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LocalData, is_local from .const import DATA_COORDINATOR, DOMAIN @@ -21,7 +21,7 @@ from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco switch.""" if is_local(config_entry): diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index d0d16ba6324..e920c2426fe 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -44,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create a coordinator for each diffuser coordinators = { - diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser, update_interval) + diffuser.hublot: RitualsDataUpdateCoordinator( + hass, entry, diffuser, update_interval + ) for diffuser in account_devices } diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 63666fc1aca..97e9c8418d1 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser binary sensors.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index a83e823bd4e..bbcb24b3e65 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -5,6 +5,7 @@ import logging from pyrituals import Diffuser +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,9 +17,12 @@ _LOGGER = logging.getLogger(__name__) class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, diffuser: Diffuser, update_interval: timedelta, ) -> None: @@ -27,6 +31,7 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}-{diffuser.hublot}", update_interval=update_interval, ) diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 0ac9c30f285..98e833ff9bd 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -11,7 +11,7 @@ from pyrituals import Diffuser from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -41,7 +41,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser numbers.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 27aff70649b..c239627e9c6 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfArea from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser select entities.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 46faa8d73e9..3921fd0b6c2 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -60,7 +60,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser sensors.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index b5828f5ca07..c5331b49078 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -11,7 +11,7 @@ from pyrituals import Diffuser from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -42,7 +42,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser switch.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 8fd437e7e1d..c3217d9334e 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIMEOUT, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle @@ -271,11 +271,9 @@ class RMVDepartureData: if not dest_found: continue - if ( - self._lines - and journey["number"] not in self._lines - or journey["minutes"] < self._time_offset - ): + if (self._lines and journey["number"] not in self._lines) or journey[ + "minutes" + ] < self._time_offset: continue for attr in ("direction", "departure_time", "product", "minutes"): diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 9ab9226c9a5..1c25d527aa8 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine -from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -21,34 +20,23 @@ from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS -from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .coordinator import ( + RoborockConfigEntry, + RoborockCoordinators, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) +from .roborock_storage import async_remove_map_storage SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] - - -@dataclass -class RoborockCoordinators: - """Roborock coordinators type.""" - - v1: list[RoborockDataUpdateCoordinator] - a01: list[RoborockDataUpdateCoordinatorA01] - - def values( - self, - ) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]: - """Return all coordinators.""" - return self.v1 + self.a01 - async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" @@ -94,7 +82,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> # Get a Coordinator if the device is available or if we have connected to the device before coordinators = await asyncio.gather( *build_setup_functions( - hass, device_map, user_data, product_info, home_data.rooms + hass, + entry, + device_map, + user_data, + product_info, + home_data.rooms, + api_client, ), return_exceptions=True, ) @@ -117,13 +111,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - async def on_unload() -> None: - release_tasks = set() - for coordinator in valid_coordinators.values(): - release_tasks.add(coordinator.release()) - await asyncio.gather(*release_tasks) + async def on_stop(_: Any) -> None: + _LOGGER.debug("Shutting down roborock") + await asyncio.gather( + *( + coordinator.async_shutdown() + for coordinator in valid_coordinators.values() + ) + ) - entry.async_on_unload(on_unload) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + on_stop, + ) + ) entry.runtime_data = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -133,10 +135,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> def build_setup_functions( hass: HomeAssistant, + entry: RoborockConfigEntry, device_map: dict[str, HomeDataDevice], user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -147,7 +151,13 @@ def build_setup_functions( """Create a list of setup functions that can later be called asynchronously.""" return [ setup_device( - hass, user_data, device, product_info[device.product_id], home_data_rooms + hass, + entry, + user_data, + device, + product_info[device.product_id], + home_data_rooms, + api_client, ) for device in device_map.values() ] @@ -155,18 +165,20 @@ def build_setup_functions( async def setup_device( hass: HomeAssistant, + entry: RoborockConfigEntry, user_data: UserData, device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: """Set up a coordinator for a given device.""" if device.pv == "1.0": return await setup_device_v1( - hass, user_data, device, product_info, home_data_rooms + hass, entry, user_data, device, product_info, home_data_rooms, api_client ) if device.pv == "A01": - return await setup_device_a01(hass, user_data, device, product_info) + return await setup_device_a01(hass, entry, user_data, device, product_info) _LOGGER.warning( "Not adding device %s because its protocol version %s or category %s is not supported", device.duid, @@ -178,10 +190,12 @@ async def setup_device( async def setup_device_v1( hass: HomeAssistant, + entry: RoborockConfigEntry, user_data: UserData, device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | None: """Set up a device Coordinator.""" mqtt_client = await hass.async_add_executor_job( @@ -203,12 +217,20 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, device, networking, product_info, mqtt_client, home_data_rooms + hass, + entry, + device, + networking, + product_info, + mqtt_client, + home_data_rooms, + api_client, + user_data, ) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: - await coordinator.release() + await coordinator.async_shutdown() if isinstance(coordinator.api, RoborockMqttClientV1): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " @@ -237,6 +259,7 @@ async def setup_device_v1( async def setup_device_a01( hass: HomeAssistant, + entry: RoborockConfigEntry, user_data: UserData, device: HomeDataDevice, product_info: HomeDataProduct, @@ -245,7 +268,9 @@ async def setup_device_a01( mqtt_client = RoborockMqttClientA01( user_data, DeviceData(device, product_info.name), product_info.category ) - coord = RoborockDataUpdateCoordinatorA01(hass, device, product_info, mqtt_client) + coord = RoborockDataUpdateCoordinatorA01( + hass, entry, device, product_info, mqtt_client + ) await coord.async_config_entry_first_refresh() return coord @@ -259,3 +284,8 @@ async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> No """Handle options update.""" # Reload entry to update data await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: + """Handle removal of an entry.""" + await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index b88556ea857..db557f055dc 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -14,10 +14,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -70,7 +69,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock vacuum binary sensors.""" async_add_entities( diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 2f214c7c51c..33e9502aca1 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -9,10 +9,9 @@ from roborock.roborock_typing import RoborockCommand from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 @@ -63,7 +62,7 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" async_add_entities( diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 4a9bd14bfe1..fe9091a3ea7 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -36,6 +36,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, + Platform.SCENE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, @@ -49,5 +50,7 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_FILE_FORMAT = "PNG" +MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 443e50642f2..b35f62323e8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,22 +2,34 @@ from __future__ import annotations +import asyncio +from dataclasses import dataclass from datetime import timedelta import logging -from propcache import cached_property +from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory -from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo +from roborock.containers import ( + DeviceData, + HomeDataDevice, + HomeDataProduct, + HomeDataScene, + NetworkInfo, + UserData, +) from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.roborock_typing import DeviceProp from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 +from roborock.web_api import RoborockApiClient +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -26,26 +38,55 @@ from homeassistant.util import slugify from .const import DOMAIN from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo +from .roborock_storage import RoborockMapStorage SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +@dataclass +class RoborockCoordinators: + """Roborock coordinators type.""" + + v1: list[RoborockDataUpdateCoordinator] + a01: list[RoborockDataUpdateCoordinatorA01] + + def values( + self, + ) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]: + """Return all coordinators.""" + return self.v1 + self.a01 + + +type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] + + class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" + config_entry: RoborockConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: RoborockConfigEntry, device: HomeDataDevice, device_networking: NetworkInfo, product_info: HomeDataProduct, cloud_api: RoborockMqttClientV1, home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, + user_data: UserData, ) -> None: """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.roborock_device_info = RoborockHassDeviceInfo( device, device_networking, @@ -59,7 +100,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, + identifiers={(DOMAIN, self.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -72,6 +113,11 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} + self.map_storage = RoborockMapStorage( + hass, self.config_entry.entry_id, self.duid_slug + ) + self._user_data = user_data + self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -101,7 +147,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", - self.roborock_device_info.device.duid, + self.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -109,10 +155,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. - async def release(self) -> None: - """Disconnect from API.""" - await self.api.async_release() - await self.cloud_api.async_release() + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await super().async_shutdown() + await asyncio.gather( + self.map_storage.flush(), + self.api.async_release(), + self.cloud_api.async_release(), + ) async def _update_device_prop(self) -> None: """Update device properties.""" @@ -156,6 +206,34 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } + async def get_scenes(self) -> list[HomeDataScene]: + """Get scenes.""" + try: + return await self._api_client.get_scenes(self._user_data, self.duid) + except RoborockException as err: + _LOGGER.error("Failed to get scenes %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "get_scenes", + }, + ) from err + + async def execute_scene(self, scene_id: int) -> None: + """Execute scene.""" + try: + await self._api_client.execute_scene(self._user_data, scene_id) + except RoborockException as err: + _LOGGER.error("Failed to execute scene %s %s", scene_id, err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "execute_scene", + }, + ) from err + @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" @@ -174,15 +252,24 @@ class RoborockDataUpdateCoordinatorA01( ): """Class to manage fetching data from the API for A01 devices.""" + config_entry: RoborockConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: RoborockConfigEntry, device: HomeDataDevice, product_info: HomeDataProduct, api: RoborockClientA01, ) -> None: """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.api = api self.device_info = DeviceInfo( name=device.name, @@ -219,8 +306,9 @@ class RoborockDataUpdateCoordinatorA01( ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: return await self.api.update_values(self.request_protocols) - async def release(self) -> None: - """Disconnect from API.""" + async def async_shutdown(self) -> None: + """Shutdown the coordinator on config entry unload.""" + await super().async_shutdown() await self.api.async_release() @cached_property diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index e784e4ce837..4602b4bd02a 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from . import RoborockConfigEntry +from .coordinator import RoborockConfigEntry TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 8717920b907..6d9e87b0556 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,35 +1,40 @@ """Support for Roborock image.""" import asyncio +from collections.abc import Callable from datetime import datetime import io -from itertools import chain from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette -from vacuum_map_parser_base.config.drawable import Drawable from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Sizes from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util -from . import RoborockConfigEntry -from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP -from .coordinator import RoborockDataUpdateCoordinator +from .const import ( + DEFAULT_DRAWABLES, + DOMAIN, + DRAWABLES, + IMAGE_CACHE_INTERVAL, + MAP_FILE_FORMAT, + MAP_SLEEP, +) +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock image platform.""" @@ -38,17 +43,35 @@ async def async_setup_entry( for drawable, default_value in DEFAULT_DRAWABLES.items() if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] - entities = list( - chain.from_iterable( - await asyncio.gather( - *( - create_coordinator_maps(coord, drawables) - for coord in config_entry.runtime_data.v1 - ) - ) - ) + parser = RoborockMapDataParser( + ColorsPalette(), Sizes(), drawables, ImageConfig(), [] + ) + + def parse_image(map_bytes: bytes) -> bytes | None: + parsed_map = parser.parse(map_bytes) + if parsed_map.image is None: + return None + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue() + + await asyncio.gather( + *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) + ) + async_add_entities( + ( + RoborockMap( + config_entry, + f"{coord.duid_slug}_map_{map_info.name}", + coord, + map_info.flag, + map_info.name, + parse_image, + ) + for coord in config_entry.runtime_data.v1 + for map_info in coord.maps.values() + ), ) - async_add_entities(entities) class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): @@ -56,39 +79,27 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): _attr_has_entity_name = True image_last_updated: datetime + _attr_name: str def __init__( self, + config_entry: ConfigEntry, unique_id: str, coordinator: RoborockDataUpdateCoordinator, map_flag: int, - starting_map: bytes, map_name: str, - drawables: list[Drawable], + parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) + self.config_entry = config_entry self._attr_name = map_name - self.parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] - ) - self._attr_image_last_updated = dt_util.utcnow() + self.parser = parser self.map_flag = map_flag - try: - self.cached_map = self._create_image(starting_map) - except HomeAssistantError: - # If we failed to update the image on init, - # we set cached_map to empty bytes - # so that we are unavailable and can try again later. - self.cached_map = b"" + self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def available(self) -> bool: - """Determines if the entity is available.""" - return self.cached_map != b"" - @property def is_selected(self) -> bool: """Return if this map is the currently selected map.""" @@ -107,6 +118,14 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass load any previously cached maps from disk.""" + await super().async_added_to_hass() + content = await self.coordinator.map_storage.async_load_map(self.map_flag) + self.cached_map = content or b"" + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should @@ -127,47 +146,36 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ), return_exceptions=True, ) - if not isinstance(response[0], bytes): + if ( + not isinstance(response[0], bytes) + or (content := self.parser(response[0])) is None + ): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) - map_data = response[0] - self.cached_map = self._create_image(map_data) + if self.cached_map != content: + self.cached_map = content + await self.coordinator.map_storage.async_save_map( + self.map_flag, + content, + ) return self.cached_map - def _create_image(self, map_bytes: bytes) -> bytes: - """Create an image using the map parser.""" - parsed_map = self.parser.parse(map_bytes) - if parsed_map.image is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format="PNG") - return img_byte_arr.getvalue() - -async def create_coordinator_maps( - coord: RoborockDataUpdateCoordinator, drawables: list[Drawable] -) -> list[RoborockMap]: +async def refresh_coordinators( + hass: HomeAssistant, coord: RoborockDataUpdateCoordinator +) -> None: """Get the starting map information for all maps for this device. The following steps must be done synchronously. Only one map can be loaded at a time per device. """ - entities = [] cur_map = coord.current_map # This won't be None at this point as the coordinator will have run first. assert cur_map is not None - # Sort the maps so that we start with the current map and we can skip the - # load_multi_map call. - maps_info = sorted( - coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True - ) - for map_flag, map_info in maps_info: - # Load the map - so we can access it with get_map_v1 + map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: if map_flag != cur_map: # Only change the map and sleep if we have multiple maps. await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) @@ -175,28 +183,11 @@ async def create_coordinator_maps( # We cannot get the map until the roborock servers fully process the # map change. await asyncio.sleep(MAP_SLEEP) - # Get the map data - map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.set_current_map_rooms()], - return_exceptions=True, - ) - # If we fail to get the map, we should set it to empty byte, - # still create it, and set it as unavailable. - api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" - entities.append( - RoborockMap( - f"{slugify(coord.duid)}_map_{map_info.name}", - coord, - map_flag, - api_data, - map_info.name, - drawables, - ) - ) + await coord.set_current_map_rooms() + if len(coord.maps) != 1: # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) coord.current_map = cur_map - return entities diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index bb89ecedbe3..db2654d4baa 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -1,13 +1,13 @@ { "domain": "roborock", "name": "Roborock", - "codeowners": ["@Lash-L"], + "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.8.4", + "python-roborock==2.11.1", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 7f568ae824b..a710eeefb90 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -14,10 +14,10 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,7 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock number platform.""" possible_entities: list[ diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py new file mode 100644 index 00000000000..8a469b0a38e --- /dev/null +++ b/homeassistant/components/roborock/roborock_storage.py @@ -0,0 +1,95 @@ +"""Roborock storage.""" + +import logging +from pathlib import Path +import shutil + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, MAP_FILENAME_SUFFIX + +_LOGGER = logging.getLogger(__name__) + +STORAGE_PATH = f".storage/{DOMAIN}" +MAPS_PATH = "maps" + + +def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path: + return Path(hass.config.path(STORAGE_PATH)) / entry_id + + +class RoborockMapStorage: + """Store and retrieve maps for a Roborock device. + + An instance of RoborockMapStorage is created for each device and manages + local storage of maps for that device. + """ + + def __init__(self, hass: HomeAssistant, entry_id: str, device_id_slug: str) -> None: + """Initialize RoborockMapStorage.""" + self._hass = hass + self._path_prefix = ( + _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug + ) + self._write_queue: dict[int, bytes] = {} + + async def async_load_map(self, map_flag: int) -> bytes | None: + """Load maps from disk.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + return await self._hass.async_add_executor_job(self._load_map, filename) + + def _load_map(self, filename: Path) -> bytes | None: + """Load maps from disk.""" + if not filename.exists(): + return None + try: + return filename.read_bytes() + except OSError as err: + _LOGGER.debug("Unable to read map file: %s %s", filename, err) + return None + + async def async_save_map(self, map_flag: int, content: bytes) -> None: + """Save the map to a pending write queue.""" + self._write_queue[map_flag] = content + + async def flush(self) -> None: + """Flush all maps to disk.""" + _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) + + queue = self._write_queue.copy() + + def _flush_all() -> None: + for map_flag, content in queue.items(): + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + self._save_map(filename, content) + + await self._hass.async_add_executor_job(_flush_all) + self._write_queue.clear() + + def _save_map(self, filename: Path, content: bytes) -> None: + """Write the map to disk.""" + _LOGGER.debug("Saving map to disk: %s", filename) + try: + filename.parent.mkdir(parents=True, exist_ok=True) + except OSError as err: + _LOGGER.error("Unable to create map directory: %s %s", filename, err) + return + try: + filename.write_bytes(content) + except OSError as err: + _LOGGER.error("Unable to write map file: %s %s", filename, err) + + +async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None: + """Remove all map storage associated with a config entry.""" + + def remove(path_prefix: Path) -> None: + try: + if path_prefix.exists(): + shutil.rmtree(path_prefix, ignore_errors=True) + except OSError as err: + _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err) + + path_prefix = _storage_path_prefix(hass, entry_id) + _LOGGER.debug("Removing maps from disk store: %s", path_prefix) + await hass.async_add_executor_job(remove, path_prefix) diff --git a/homeassistant/components/roborock/scene.py b/homeassistant/components/roborock/scene.py new file mode 100644 index 00000000000..ff418a2810c --- /dev/null +++ b/homeassistant/components/roborock/scene.py @@ -0,0 +1,64 @@ +"""Support for Roborock scene.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.scene import Scene as SceneEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RoborockConfigEntry +from .coordinator import RoborockDataUpdateCoordinator +from .entity import RoborockEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up scene platform.""" + scene_lists = await asyncio.gather( + *[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1], + ) + async_add_entities( + RoborockSceneEntity( + coordinator, + EntityDescription( + key=str(scene.id), + name=scene.name, + ), + ) + for coordinator, scenes in zip( + config_entry.runtime_data.v1, scene_lists, strict=True + ) + for scene in scenes + ) + + +class RoborockSceneEntity(RoborockEntity, SceneEntity): + """A class to define Roborock scene entities.""" + + entity_description: EntityDescription + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Create a scene entity.""" + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator.device_info, + coordinator.api, + ) + self._scene_id = int(entity_description.key) + self._coordinator = coordinator + self.entity_description = entity_description + + async def async_activate(self, **kwargs: Any) -> None: + """Activate the scene.""" + await self._coordinator.execute_scene(self._scene_id) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 73cb95d2d7c..6133eed0652 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -11,11 +11,10 @@ from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RoborockConfigEntry from .const import MAP_SLEEP -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -65,7 +64,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock select platform.""" diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index e01a03d7720..f95dc5fa98f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -28,11 +28,14 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 @@ -292,7 +295,7 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock vacuum sensors.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7005344614c..eb058ea74e3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -23,8 +23,8 @@ "invalid_email": "There is no account associated with the email you entered, please try again.", "invalid_email_format": "There is an issue with the formatting of your email - please try again.", "too_frequent_code_requests": "You have attempted to request too many codes. Try again later.", - "unknown_roborock": "There was an unknown roborock exception - please check your logs.", - "unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.", + "unknown_roborock": "There was an unknown Roborock exception - please check your logs.", + "unknown_url": "There was an issue determining the correct URL for your Roborock account - please check your logs.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -128,7 +128,7 @@ "updating": "[%key:component::roborock::entity::sensor::status::state::updating%]", "washing": "Washing", "ready": "Ready", - "charging": "[%key:component::roborock::entity::sensor::status::state::charging%]", + "charging": "[%key:common::state::charging%]", "mop_washing": "Washing mop", "self_clean_cleaning": "Self clean cleaning", "self_clean_deep_cleaning": "Self clean deep cleaning", @@ -199,7 +199,7 @@ "cleaning": "Cleaning", "returning_home": "Returning home", "manual_mode": "Manual mode", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", @@ -436,11 +436,11 @@ "services": { "get_maps": { "name": "Get maps", - "description": "Get the map and room information of your device." + "description": "Retrieves the map and room information of your device." }, "set_vacuum_goto_position": { "name": "Go to position", - "description": "Send the vacuum to a specific position.", + "description": "Sends the vacuum to a specific position.", "fields": { "x": { "name": "X-coordinate", @@ -454,7 +454,7 @@ }, "get_vacuum_current_position": { "name": "Get current position", - "description": "Get the current position of the vacuum." + "description": "Retrieves the current position of the vacuum." } } } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index b0c8c880188..0171d59abfd 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -16,10 +16,10 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -99,7 +99,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" possible_entities: list[ diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 1dd681dff1f..6aa70e300e5 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -16,10 +16,10 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -114,7 +114,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock time platform.""" possible_entities: list[ diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 7582dadad16..59abc888673 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -16,16 +16,15 @@ from homeassistant.components.vacuum import ( from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RoborockConfigEntry from .const import ( DOMAIN, GET_MAPS_SERVICE_NAME, GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, SET_VACUUM_GOTO_POSITION_SERVICE_NAME, ) -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes @@ -59,7 +58,7 @@ STATE_CODE_TO_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock sensor.""" async_add_entities( diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index a06226d22ee..20ae0708c15 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_PASSWORD, CONF_ROOM, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index e6b92d91335..be0b20c97fb 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID -from .coordinator import RokuDataUpdateCoordinator +from .coordinator import RokuConfigEntry, RokuDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -17,22 +15,10 @@ PLATFORMS = [ Platform.SENSOR, ] -type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Set up Roku from a config entry.""" - if (device_id := entry.unique_id) is None: - device_id = entry.entry_id - - coordinator = RokuDataUpdateCoordinator( - hass, - host=entry.data[CONF_HOST], - device_id=device_id, - play_media_app_id=entry.options.get( - CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID - ), - ) + coordinator = RokuDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 2e7fd12788c..31250898055 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -13,9 +13,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity # Coordinator is used to centralize the data updates @@ -59,7 +59,7 @@ BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Roku binary sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index bc0092d6953..47bc86802d2 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse from rokuecp import Roku, RokuError import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -19,9 +18,15 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import RokuConfigEntry from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN +from .coordinator import RokuConfigEntry DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -117,7 +122,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by homekit discovery.""" @@ -147,12 +152,12 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info.ssdp_location).hostname - name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py index 7900669d02f..e3c20d8351f 100644 --- a/homeassistant/components/roku/coordinator.py +++ b/homeassistant/components/roku/coordinator.py @@ -8,33 +8,44 @@ import logging from rokuecp import Roku, RokuError from rokuecp.models import Device +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import utcnow -from .const import DOMAIN +from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN REQUEST_REFRESH_DELAY = 0.35 SCAN_INTERVAL = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) +type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator] + class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Class to manage fetching Roku data.""" + config_entry: RokuConfigEntry last_full_update: datetime | None roku: Roku def __init__( - self, hass: HomeAssistant, *, host: str, device_id: str, play_media_app_id: str + self, + hass: HomeAssistant, + config_entry: RokuConfigEntry, ) -> None: """Initialize global Roku data updater.""" - self.device_id = device_id - self.roku = Roku(host=host, session=async_get_clientsession(hass)) - self.play_media_app_id = play_media_app_id + self.device_id = config_entry.unique_id or config_entry.entry_id + self.roku = Roku( + host=config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + self.play_media_app_id = config_entry.options.get( + CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID + ) self.full_update_interval = timedelta(minutes=15) self.last_full_update = None @@ -42,6 +53,7 @@ class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, # We don't want an immediate refresh since the device diff --git a/homeassistant/components/roku/diagnostics.py b/homeassistant/components/roku/diagnostics.py index e98837ca442..86e7a7ac1c9 100644 --- a/homeassistant/components/roku/diagnostics.py +++ b/homeassistant/components/roku/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index 259cb092cb8..1321e3806d1 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -6,8 +6,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RokuDataUpdateCoordinator from .const import DOMAIN +from .coordinator import RokuDataUpdateCoordinator class RokuEntity(CoordinatorEntity[RokuDataUpdateCoordinator]): diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 0c1f92521af..d0e1e3a53c0 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -26,10 +26,9 @@ from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import RokuConfigEntry from .browse_media import async_browse_media from .const import ( ATTR_ARTIST_NAME, @@ -40,7 +39,7 @@ from .const import ( ATTR_THUMBNAIL, SERVICE_SEARCH, ) -from .coordinator import RokuDataUpdateCoordinator +from .coordinator import RokuConfigEntry, RokuDataUpdateCoordinator from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler @@ -83,7 +82,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: RokuConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roku config entry.""" async_add_entities( diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index f7916fb23a2..cc3689c9df3 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -7,9 +7,9 @@ from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity from .helpers import roku_exception_handler @@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Roku remote based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 360d4e25415..062e1258ea2 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -10,9 +10,9 @@ from rokuecp.models import Device as RokuDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler @@ -109,7 +109,7 @@ CHANNEL_ENTITY = RokuSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roku select based on a config entry.""" device: RokuDevice = entry.runtime_data.data diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 870386945a6..a61a9be6a73 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -10,9 +10,9 @@ from rokuecp.models import Device as RokuDevice from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity # Coordinator is used to centralize the data updates @@ -45,7 +45,7 @@ SENSORS: tuple[RokuSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roku sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py index 352f5f3715a..be227645122 100644 --- a/homeassistant/components/romy/__init__.py +++ b/homeassistant/components/romy/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_HOST], config_entry.data.get(CONF_PASSWORD, "") ) - coordinator = RomyVacuumCoordinator(hass, new_romy) + coordinator = RomyVacuumCoordinator(hass, config_entry, new_romy) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/romy/binary_sensor.py b/homeassistant/components/romy/binary_sensor.py index d8f6216007f..599c0fe023e 100644 --- a/homeassistant/components/romy/binary_sensor.py +++ b/homeassistant/components/romy/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RomyVacuumCoordinator @@ -39,7 +39,7 @@ BINARY_SENSORS: list[BinarySensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index e571ff41c9a..48558cd98c7 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -5,10 +5,10 @@ from __future__ import annotations import romy import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -84,7 +84,7 @@ class RomyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index 5868eae70e2..d666ec44f80 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -2,6 +2,7 @@ from romy import RomyRobot +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -11,9 +12,19 @@ from .const import DOMAIN, LOGGER, UPDATE_INTERVAL class RomyVacuumCoordinator(DataUpdateCoordinator[None]): """ROMY Vacuum Coordinator.""" - def __init__(self, hass: HomeAssistant, romy: RomyRobot) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, romy: RomyRobot + ) -> None: """Initialize.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) self.hass = hass self.romy = romy diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py index 341125b86ba..85bf0df8f64 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RomyVacuumCoordinator @@ -77,7 +77,7 @@ SENSORS: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index 49129daabbd..0e9dd13ffe1 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER from .coordinator import RomyVacuumCoordinator @@ -51,7 +51,7 @@ SUPPORT_ROMY_ROBOT = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index baf66375036..d50535c885a 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import roomba_reported_state from .const import DOMAIN @@ -14,7 +14,7 @@ from .models import RoombaData async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d040074246a..b7d259e3131 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,7 +11,6 @@ from roombapy.discovery import RoombaDiscovery from roombapy.getpassword import RoombaPassword import voluptuous as vol -from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( @@ -95,7 +96,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): return RoombaOptionsFlowHandler() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" return await self._async_step_discovery( @@ -103,7 +104,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" return await self._async_step_discovery( diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index d55a260e53a..14c7ac3af3e 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -3,10 +3,10 @@ from __future__ import annotations from homeassistant.const import ATTR_CONNECTIONS -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import roomba_reported_state from .const import DOMAIN @@ -80,7 +80,7 @@ class IRobotEntity(Entity): return None return dt_util.utc_from_timestamp(ts) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callback function.""" self.vacuum.register_on_message_callback(self.on_message) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index edb317f9752..dbfd803f89b 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.8.1"], + "requirements": ["roombapy==1.9.0"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index d358dcb428c..3a98bedcd94 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -125,7 +125,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 92063f74afa..10606814a35 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -14,7 +14,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM @@ -89,7 +89,7 @@ SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index b896f6775ae..3421cbf646c 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( AUTHENTICATE_TIMEOUT, diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index 7bc6ea27dd9..2f2967c5789 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon Event from Config Entry.""" roon_server = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 3b1735cd2fc..0460e2cfc6e 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -25,7 +25,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import convert from homeassistant.util.dt import utcnow @@ -52,7 +52,7 @@ REPEAT_MODE_MAPPING_TO_ROON = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon MediaPlayer from Config Entry.""" roon_server = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index 92094b0b608..2c9824d0628 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rova/__init__.py b/homeassistant/components/rova/__init__.py index 64f0e787a4b..ecde0578772 100644 --- a/homeassistant/components/rova/__init__.py +++ b/homeassistant/components/rova/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryError("Rova does not collect garbage in this area") - coordinator = RovaCoordinator(hass, api) + coordinator = RovaCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rova/coordinator.py b/homeassistant/components/rova/coordinator.py index ecd91cad823..a48048d32c3 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from rova.rova import Rova +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import get_time_zone @@ -16,11 +17,16 @@ EUROPE_AMSTERDAM_ZONE_INFO = get_time_zone("Europe/Amsterdam") class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Class to manage fetching Rova data.""" - def __init__(self, hass: HomeAssistant, api: Rova) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: Rova + ) -> None: """Initialize.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(hours=12), ) diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 589183eb7a8..59f9f28f8f5 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Rova entry.""" coordinator: RovaCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rpi_power/binary_sensor.py b/homeassistant/components/rpi_power/binary_sensor.py index 00d7ec0e3f4..1424148f554 100644 --- a/homeassistant/components/rpi_power/binary_sensor.py +++ b/homeassistant/components/rpi_power/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ DESCRIPTION_UNDER_VOLTAGE = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up rpi_power binary sensor.""" under_voltage = await hass.async_add_executor_job(new_under_voltage) diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 89624c922e6..98d0e1bf790 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 654288927d3..70fe7919edb 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 4ee870e8322..8e9219985ce 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await ruckus.close() raise ConfigEntryAuthFailed from autherr - coordinator = RuckusDataUpdateCoordinator(hass, ruckus=ruckus) + coordinator = RuckusDataUpdateCoordinator(hass, entry, ruckus) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index d9f20883559..7ffaab2e977 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -6,6 +6,7 @@ import logging from aioruckus import AjaxSession from aioruckus.exceptions import AuthenticationError, SchemaError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,17 +19,20 @@ _LOGGER = logging.getLogger(__package__) class RuckusDataUpdateCoordinator(DataUpdateCoordinator): """Coordinator to manage data from Ruckus client.""" - def __init__(self, hass: HomeAssistant, *, ruckus: AjaxSession) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, ruckus: AjaxSession + ) -> None: """Initialize global Ruckus data updater.""" self.ruckus = ruckus - update_interval = timedelta(seconds=SCAN_INTERVAL) - super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=timedelta(seconds=SCAN_INTERVAL), ) async def _fetch_clients(self) -> dict: diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 8a5e8b79294..890148ec25c 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -25,7 +25,9 @@ _LOGGER = logging.getLogger(__package__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Ruckus component.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] @@ -69,7 +71,7 @@ def restore_entities( registry: er.EntityRegistry, coordinator: RuckusDataUpdateCoordinator, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Restore clients that are not a part of active clients list.""" diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index 5618a424726..edf542b5de2 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -8,7 +8,6 @@ from typing import Any from aiorussound import RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, @@ -16,6 +15,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -39,7 +39,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self.data: dict[str, Any] = {} async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self.data[CONF_HOST] = host = discovery_info.host diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 62981262e32..b40b82862f9 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt import logging from typing import TYPE_CHECKING @@ -19,7 +20,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RussoundConfigEntry from .entity import RussoundBaseEntity, command @@ -32,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: RussoundConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Russound RIO platform.""" client = entry.runtime_data @@ -57,6 +58,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SEEK ) def __init__( @@ -138,6 +140,21 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): """Image url of current playing media.""" return self._source.cover_art_url + @property + def media_duration(self) -> int | None: + """Duration of the current media.""" + return self._source.track_time + + @property + def media_position(self) -> int | None: + """Position of the current media.""" + return self._source.play_time + + @property + def media_position_updated_at(self) -> dt.datetime: + """Last time the media position was updated.""" + return self._source.position_last_updated + @property def volume_level(self) -> float: """Volume level of the media player (0..1). @@ -199,3 +216,8 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): if mute != self.is_volume_muted: await self._zone.toggle_mute() + + @command + async def async_media_seek(self, position: float) -> None: + """Seek to a position in the current media.""" + await self._zone.set_seek_time(int(position)) diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index 27fbfbca57f..58925b4b1ff 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -1,7 +1,7 @@ { "domain": "russound_rnet", "name": "Russound RNET", - "codeowners": [], + "codeowners": ["@noahhusby"], "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", "loggers": ["russound"], diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index f8369ed64ca..48808930d9f 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/russound_rnet/quality_scale.yaml b/homeassistant/components/russound_rnet/quality_scale.yaml new file mode 100644 index 00000000000..b82ef6f4643 --- /dev/null +++ b/homeassistant/components/russound_rnet/quality_scale.yaml @@ -0,0 +1,95 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: todo + brands: done + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: + status: todo + comment: | + CI pipeline for publishing is not on GH repo. + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + The device does not support discovery. + discovery: + status: exempt + comment: | + The device does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: + status: exempt + comment: | + There are no entities to translate. + exception-translations: todo + icon-translations: + status: exempt + comment: | + There are no entities that require icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. An issue will be implemented for yaml import once a config flow is added. + stale-devices: + status: exempt + comment: | + This integration is not a hub and only represents a single device. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + This integration uses telnet or serial exclusively and does not make http calls. + strict-typing: todo diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py index 77b3e9b57de..da93a89a9f3 100644 --- a/homeassistant/components/ruuvi_gateway/__init__.py +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -5,11 +5,10 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from .bluetooth import async_connect_scanner -from .const import DOMAIN, SCAN_INTERVAL +from .const import DOMAIN from .coordinator import RuuviGatewayUpdateCoordinator from .models import RuuviGatewayRuntimeData @@ -18,14 +17,7 @@ _LOGGER = logging.getLogger(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ruuvi Gateway from a config entry.""" - coordinator = RuuviGatewayUpdateCoordinator( - hass, - logger=_LOGGER, - name=entry.title, - update_interval=SCAN_INTERVAL, - host=entry.data[CONF_HOST], - token=entry.data[CONF_TOKEN], - ) + coordinator = RuuviGatewayUpdateCoordinator(hass, entry, _LOGGER) scanner, unload_scanner = async_connect_scanner(hass, entry, coordinator) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RuuviGatewayRuntimeData( update_coordinator=coordinator, diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py index c22f100e87a..05ca93de9f2 100644 --- a/homeassistant/components/ruuvi_gateway/config_flow.py +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -8,11 +8,11 @@ from typing import Any import aioruuvigateway.api as gw_api from aioruuvigateway.excs import CannotConnect, InvalidAuth -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import DOMAIN from .schemata import CONFIG_SCHEMA, get_config_schema_with_default_host @@ -82,7 +82,7 @@ class RuuviConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Prepare configuration for a DHCP discovered Ruuvi Gateway.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/ruuvi_gateway/coordinator.py b/homeassistant/components/ruuvi_gateway/coordinator.py index ba72dfe4cbc..0c42cd0cb38 100644 --- a/homeassistant/components/ruuvi_gateway/coordinator.py +++ b/homeassistant/components/ruuvi_gateway/coordinator.py @@ -2,34 +2,41 @@ from __future__ import annotations -from datetime import timedelta import logging from aioruuvigateway.api import get_gateway_history_data from aioruuvigateway.models import TagData +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import SCAN_INTERVAL + class RuuviGatewayUpdateCoordinator(DataUpdateCoordinator[list[TagData]]): """Polls the gateway for data and returns a list of TagData objects that have changed since the last poll.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, - *, - name: str, - update_interval: timedelta | None = None, - host: str, - token: str, ) -> None: """Initialize the coordinator using the given configuration (host, token).""" - super().__init__(hass, logger, name=name, update_interval=update_interval) - self.host = host - self.token = token + super().__init__( + hass, + logger, + config_entry=config_entry, + name=config_entry.title, + update_interval=SCAN_INTERVAL, + ) + self.host = config_entry.data[CONF_HOST] + self.token = config_entry.data[CONF_TOKEN] self.last_tag_datas: dict[str, TagData] = {} async def _async_update_data(self) -> list[TagData]: diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index ef287753ed4..57248d547ba 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -126,7 +126,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ruuvitag BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index f24735f4ed0..20d208cca69 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data={**data, CONF_TOKEN: token}, ) - coordinator = RymProDataUpdateCoordinator(hass, rympro) + coordinator = RymProDataUpdateCoordinator(hass, entry, rympro) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 19f16005578..6b49a065d35 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -7,6 +7,7 @@ import logging from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,13 +21,18 @@ _LOGGER = logging.getLogger(__name__) class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): """Class to manage fetching RYM Pro data.""" - def __init__(self, hass: HomeAssistant, rympro: RymPro) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, rympro: RymPro + ) -> None: """Initialize global RymPro data updater.""" self.rympro = rympro interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, ) @@ -36,11 +42,16 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): try: meters = await self.rympro.last_read() for meter_id, meter in meters.items(): + meter["monthly_consumption"] = await self.rympro.monthly_consumption( + meter_id + ) + meter["daily_consumption"] = await self.rympro.daily_consumption( + meter_id + ) meter["consumption_forecast"] = await self.rympro.consumption_forecast( meter_id ) except UnauthorizedError as error: - assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) raise UpdateFailed(error) from error except (CannotConnectError, OperationError) as error: diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 046e778f05b..51c26b312fb 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.8"] + "requirements": ["pyrympro==0.0.9"] } diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 8bb0af6e9ff..66ed41a4ce9 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -36,6 +36,20 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( suggested_display_precision=3, value_key="read", ), + RymProSensorEntityDescription( + key="monthly_consumption", + translation_key="monthly_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + value_key="monthly_consumption", + ), + RymProSensorEntityDescription( + key="daily_consumption", + translation_key="daily_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + value_key="daily_consumption", + ), RymProSensorEntityDescription( key="monthly_forecast", translation_key="monthly_forecast", @@ -48,7 +62,7 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json index 2c1e2ad93c9..589e91a6c6f 100644 --- a/homeassistant/components/rympro/strings.json +++ b/homeassistant/components/rympro/strings.json @@ -23,6 +23,12 @@ "total_consumption": { "name": "Total consumption" }, + "monthly_consumption": { + "name": "Monthly consumption" + }, + "daily_consumption": { + "name": "Daily consumption" + }, "monthly_forecast": { "name": "Monthly forecast" } diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index fee459340f3..1f68781a3a2 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -11,8 +11,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py index 1d65bf01211..59ef17237e2 100644 --- a/homeassistant/components/sabnzbd/binary_sensor.py +++ b/homeassistant/components/sabnzbd/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SabnzbdConfigEntry from .entity import SabnzbdEntity @@ -40,7 +40,7 @@ BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sabnzbd sensor entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py index 1ff26b41655..25c11f6b2ec 100644 --- a/homeassistant/components/sabnzbd/button.py +++ b/homeassistant/components/sabnzbd/button.py @@ -9,7 +9,7 @@ from pysabnzbd import SabnzbdApiException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator @@ -40,7 +40,7 @@ BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py index 53c8d462f11..63b2206ac70 100644 --- a/homeassistant/components/sabnzbd/number.py +++ b/homeassistant/components/sabnzbd/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator @@ -48,7 +48,7 @@ NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SABnzbd number entity.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 662ae739d15..5e871b4bf40 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import SabnzbdConfigEntry @@ -115,7 +115,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sabnzbd sensor entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index c8b40fd5476..89b6658c418 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.start import async_at_start diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 6d4e491b839..e416cd35765 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -44,14 +44,11 @@ from .const import ( UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -from .coordinator import SamsungTVDataUpdateCoordinator +from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] -SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] - - @callback def _async_get_device_bridge( hass: HomeAssistant, data: dict[str, Any] @@ -165,7 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> entry.async_on_unload(debounced_reloader.async_shutdown) entry.async_on_unload(entry.add_update_listener(debounced_reloader.async_call)) - coordinator = SamsungTVDataUpdateCoordinator(hass, bridge) + coordinator = SamsungTVDataUpdateCoordinator(hass, entry, bridge) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 837651f9900..3f34520e87a 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -12,7 +12,6 @@ import getmac from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator import voluptuous as vol -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -32,6 +31,14 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .bridge import SamsungTVBridge, async_get_device_info, mac_from_device_info from .const import ( @@ -59,7 +66,7 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): def _strip_uuid(udn: str) -> str: - return udn[5:] if udn.startswith("uuid:") else udn + return udn.removeprefix("uuid:") def _entry_is_complete( @@ -439,11 +446,11 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow(RESULT_NOT_SUPPORTED) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) - model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" + model_name: str = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" if discovery_info.ssdp_st == UPNP_SVC_RENDERING_CONTROL: self._ssdp_rendering_control_location = discovery_info.ssdp_location LOGGER.debug( @@ -456,12 +463,10 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): "Set SSDP MainTvAgent location to: %s", self._ssdp_main_tv_agent_location, ) - self._udn = self._upnp_udn = _strip_uuid( - discovery_info.upnp[ssdp.ATTR_UPNP_UDN] - ) + self._udn = self._upnp_udn = _strip_uuid(discovery_info.upnp[ATTR_UPNP_UDN]) if hostname := urlparse(discovery_info.ssdp_location or "").hostname: self._host = hostname - self._manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) + self._manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) self._abort_if_manufacturer_is_not_samsung() # Set defaults, in case they cannot be extracted from device_info @@ -486,7 +491,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) @@ -498,7 +503,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 92d8dc8fa84..443e62b13fb 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -15,17 +15,25 @@ from .const import DOMAIN, LOGGER SCAN_INTERVAL = 10 +type SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] + class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): """Coordinator for the SamsungTV integration.""" - config_entry: ConfigEntry + config_entry: SamsungTVConfigEntry - def __init__(self, hass: HomeAssistant, bridge: SamsungTVBridge) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SamsungTVConfigEntry, + bridge: SamsungTVBridge, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL), ) diff --git a/homeassistant/components/samsungtv/diagnostics.py b/homeassistant/components/samsungtv/diagnostics.py index ebca8d2543b..667d23ba631 100644 --- a/homeassistant/components/samsungtv/diagnostics.py +++ b/homeassistant/components/samsungtv/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from . import SamsungTVConfigEntry from .const import CONF_SESSION_ID +from .coordinator import SamsungTVConfigEntry TO_REDACT = {CONF_TOKEN, CONF_SESSION_ID} diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py index 4e8dd00d486..b4075b8117f 100644 --- a/homeassistant/components/samsungtv/helpers.py +++ b/homeassistant/components/samsungtv/helpers.py @@ -7,9 +7,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import SamsungTVConfigEntry from .bridge import SamsungTVBridge from .const import DOMAIN +from .coordinator import SamsungTVConfigEntry @callback diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index a1fda25589e..6a30efd64f8 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,11 +35,11 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.9.4", + "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.42.0" + "async-upnp-client==0.43.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 7180e8a0c1a..4e6ecfd3593 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -31,13 +31,12 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.async_ import create_eager_task -from . import SamsungTVConfigEntry from .bridge import SamsungTVWSBridge from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER -from .coordinator import SamsungTVDataUpdateCoordinator +from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator from .entity import SamsungTVEntity SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -64,7 +63,7 @@ APP_LIST_DELAY = 3 async def async_setup_entry( hass: HomeAssistant, entry: SamsungTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Samsung TV from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 401a5d383f0..d6fef262d91 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -7,17 +7,17 @@ from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SamsungTVConfigEntry from .const import LOGGER +from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity async def async_setup_entry( hass: HomeAssistant, entry: SamsungTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Samsung TV from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py index c8c5567eedc..60cc5b56f2e 100644 --- a/homeassistant/components/sanix/__init__.py +++ b/homeassistant/components/sanix/__init__.py @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = entry.data[CONF_TOKEN] sanix_api = Sanix(serial_no, token) - coordinator = SanixCoordinator(hass, sanix_api) + coordinator = SanixCoordinator(hass, entry, sanix_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py index d6362337a38..64d28fa9191 100644 --- a/homeassistant/components/sanix/coordinator.py +++ b/homeassistant/components/sanix/coordinator.py @@ -21,10 +21,16 @@ class SanixCoordinator(DataUpdateCoordinator[Measurement]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, sanix_api: Sanix) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, sanix_api: Sanix + ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=MANUFACTURER, update_interval=timedelta(hours=1) + hass, + _LOGGER, + config_entry=config_entry, + name=MANUFACTURER, + update_interval=timedelta(hours=1), ) self._sanix_api = sanix_api diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py index 39a1c593433..d2a1aecb099 100644 --- a/homeassistant/components/sanix/sensor.py +++ b/homeassistant/components/sanix/sensor.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -82,7 +82,9 @@ SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sanix Sensor entities based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 39c0d6b876d..41b2d0d561b 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -69,6 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity): def __init__(self, controller, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" self._attr_name = name + self._attr_unique_id = f"satel_alarm_panel_{partition_id}" self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py index 6ce82908de7..9135b58bc50 100644 --- a/homeassistant/components/satel_integra/switch.py +++ b/homeassistant/components/satel_integra/switch.py @@ -58,6 +58,7 @@ class SatelIntegraSwitch(SwitchEntity): def __init__(self, controller, device_number, device_name, code): """Initialize the binary_sensor.""" self._device_number = device_number + self._attr_unique_id = f"satel_switch_{device_number}" self._name = device_name self._state = False self._code = code diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index 3fa750bf4ef..4c3e7ad43fe 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -38,12 +38,12 @@ "description": "The entity ID of the new scene." }, "entities": { - "name": "Entities state", - "description": "List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead." + "name": "Entity states", + "description": "List of entities and their target state. If your entities are already in the target state right now, use 'Entities snapshot' instead." }, "snapshot_entities": { - "name": "Snapshot entities", - "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." + "name": "Entities snapshot", + "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine 'Entities snapshot' with 'Entity states'." } } }, @@ -54,7 +54,7 @@ }, "exceptions": { "entity_not_scene": { - "message": "{entity_id} is not a valid scene entity_id." + "message": "{entity_id} is not a valid entity ID of a scene." }, "entity_not_dynamically_created": { "message": "The scene {entity_id} is not created with action `scene.create`." diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 24ce4f3b3fa..ea569f4e277 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -18,7 +18,14 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.collection import ( CollectionEntity, DictStorageCollection, @@ -28,7 +35,6 @@ from homeassistant.helpers.collection import ( YamlCollection, sync_entity_lifecycle, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.service import async_register_admin_service @@ -44,6 +50,7 @@ from .const import ( CONF_TO, DOMAIN, LOGGER, + SERVICE_GET, WEEKDAY_TO_CONF, ) @@ -73,7 +80,7 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: ) # Check if the from time of the event is after the to time of the previous event - if previous_to is not None and previous_to > time_range[CONF_FROM]: # type: ignore[unreachable] + if previous_to is not None and previous_to > time_range[CONF_FROM]: raise vol.Invalid("Overlapping times found in schedule") previous_to = time_range[CONF_TO] @@ -205,6 +212,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: reload_service_handler, ) + component.async_register_entity_service( + SERVICE_GET, + {}, + async_get_schedule_service, + supports_response=SupportsResponse.ONLY, + ) + await component.async_setup(config) + return True @@ -296,6 +311,10 @@ class Schedule(CollectionEntity): self.async_on_remove(self._clean_up_listener) self._update() + def get_schedule(self) -> ConfigType: + """Return the schedule.""" + return {d: self._config[d] for d in WEEKDAY_TO_CONF.values()} + @callback def _update(self, _: datetime | None = None) -> None: """Update the states of the schedule.""" @@ -390,3 +409,10 @@ class Schedule(CollectionEntity): data_keys.update(time_range_custom_data.keys()) return frozenset(data_keys) + + +async def async_get_schedule_service( + schedule: Schedule, service_call: ServiceCall +) -> ServiceResponse: + """Return the schedule configuration.""" + return schedule.get_schedule() diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py index 6687dafefdb..410cd00c3a0 100644 --- a/homeassistant/components/schedule/const.py +++ b/homeassistant/components/schedule/const.py @@ -37,3 +37,5 @@ WEEKDAY_TO_CONF: Final = { 5: CONF_SATURDAY, 6: CONF_SUNDAY, } + +SERVICE_GET: Final = "get_schedule" diff --git a/homeassistant/components/schedule/icons.json b/homeassistant/components/schedule/icons.json index a9829425570..7d631cfd42d 100644 --- a/homeassistant/components/schedule/icons.json +++ b/homeassistant/components/schedule/icons.json @@ -2,6 +2,9 @@ "services": { "reload": { "service": "mdi:reload" + }, + "get_schedule": { + "service": "mdi:calendar-export" } } } diff --git a/homeassistant/components/schedule/services.yaml b/homeassistant/components/schedule/services.yaml index c983a105c93..1cb3f0280af 100644 --- a/homeassistant/components/schedule/services.yaml +++ b/homeassistant/components/schedule/services.yaml @@ -1 +1,5 @@ reload: +get_schedule: + target: + entity: + domain: schedule diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index a40c5814d36..8638e4a8a84 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -25,6 +25,10 @@ "reload": { "name": "[%key:common::action::reload%]", "description": "Reloads schedules from the YAML-configuration." + }, + "get_schedule": { + "name": "Get schedule", + "description": "Retrieve one or multiple schedules." } } } diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 6eae69d9542..509a335aafe 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -5,12 +5,11 @@ from __future__ import annotations from pycognito.exceptions import WarrantException import pyschlage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .coordinator import SchlageDataUpdateCoordinator +from .coordinator import SchlageConfigEntry, SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -20,8 +19,6 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] -type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool: """Set up Schlage from a config entry.""" @@ -32,7 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> b except WarrantException as ex: raise ConfigEntryAuthFailed from ex - coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) + coordinator = SchlageDataUpdateCoordinator( + hass, entry, username, pyschlage.Schlage(auth) + ) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index f928d42b3ee..62e69b5cb4a 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -12,10 +12,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SchlageConfigEntry -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -40,7 +39,7 @@ _DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary_sensors based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index b319b21be0c..eec143c574f 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -13,7 +13,7 @@ from pyschlage.log import LockLog from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -34,15 +34,28 @@ class SchlageData: locks: dict[str, LockData] +type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator] + + class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): """The Schlage data update coordinator.""" - config_entry: ConfigEntry + config_entry: SchlageConfigEntry - def __init__(self, hass: HomeAssistant, username: str, api: Schlage) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SchlageConfigEntry, + username: str, + api: Schlage, + ) -> None: """Initialize the class.""" super().__init__( - hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} ({username})", + update_interval=UPDATE_INTERVAL, ) self.data = SchlageData(locks={}) self.api = api diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py index ec4d9c489e3..357f04f00db 100644 --- a/homeassistant/components/schlage/diagnostics.py +++ b/homeassistant/components/schlage/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import SchlageConfigEntry +from .coordinator import SchlageConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index d203913191d..83abf9214e3 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -6,17 +6,16 @@ from typing import Any from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SchlageConfigEntry -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Schlage WiFi locks based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index 6cf0853835f..4648686aaac 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -5,10 +5,9 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SchlageConfigEntry -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity _DESCRIPTIONS = ( @@ -33,7 +32,7 @@ _DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up selects based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index a15d1740b91..494efc7585a 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -8,12 +8,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ @@ -29,8 +28,8 @@ _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SchlageConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index 39fe6dbbc99..c40d0c41e88 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -14,12 +14,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -56,8 +55,8 @@ SWITCHES: tuple[SchlageSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SchlageConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py index 907841a2e5e..f7a8b631a05 100644 --- a/homeassistant/components/schluter/__init__.py +++ b/homeassistant/components/schluter/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index ff991c5f348..68a8cf62fe4 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -19,8 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 5ee837f32d1..b8ad9cb8a56 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -21,7 +21,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -92,7 +95,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ScrapeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Scrape sensor entry.""" entities: list = [] diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 4a178c60d81..a846a9fa4e3 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( @@ -195,7 +195,7 @@ SUPPORTED_SCG_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index e44d9b18ae1..03aebadbba6 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription @@ -42,7 +42,7 @@ SUPPORTED_PRESETS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 19db89dc03d..b4deb9b36aa 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -10,7 +10,6 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA from screenlogicpy.requests import login import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -19,8 +18,9 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL @@ -91,7 +91,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_gateway_select() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" mac = format_mac(discovery_info.macaddress) @@ -105,7 +105,7 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_gateway_select(self, user_input=None) -> ConfigFlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" - existing = self._async_current_ids() + existing = self._async_current_ids(include_ignore=False) unconfigured_gateways = { mac: gateway[SL_GATEWAY_NAME] for mac, gateway in self.discovered_gateways.items() diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index a90c9cb2cf4..b3c438dc641 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -52,6 +52,8 @@ async def async_get_connect_info( class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -60,7 +62,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): gateway: ScreenLogicGateway, ) -> None: """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry self.gateway = gateway interval = timedelta( @@ -69,6 +70,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, # Debounced option since the device takes @@ -91,7 +93,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None try: if not self.gateway.is_connected: connect_info = await async_get_connect_info( diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 412b2df5f81..b0bd154b66d 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LIGHT_CIRCUIT_FUNCTIONS from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription @@ -22,7 +22,7 @@ from .types import ScreenLogicConfigEntry async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" entities: list[ScreenLogicLight] = [] diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 3634147e509..ea9bf8ac95d 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -17,7 +17,7 @@ from homeassistant.components.number import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( @@ -104,7 +104,7 @@ SUPPORTED_SCG_NUMBERS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" entities: list[ScreenLogicNumber] = [] diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 7a5e910923c..95a7e3a5c75 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( @@ -272,7 +272,7 @@ SUPPORTED_SCG_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 09e64808dfe..97e12277eb6 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -3,9 +3,9 @@ "service_config_entry_name": "Config entry", "service_config_entry_description": "The config entry to use for this action.", "climate_preset_solar": "Solar", - "climate_preset_solar_preferred": "Solar Preferred", + "climate_preset_solar_preferred": "Solar preferred", "climate_preset_heater": "Heater", - "climate_preset_dont_change": "Don't Change" + "climate_preset_dont_change": "Don't change" }, "config": { "flow_title": "{name}", @@ -15,7 +15,7 @@ "step": { "gateway_entry": { "title": "ScreenLogic", - "description": "Enter your ScreenLogic Gateway information.", + "description": "Enter your ScreenLogic gateway information.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" @@ -46,7 +46,7 @@ }, "services": { "set_color_mode": { - "name": "Set Color Mode", + "name": "Set color mode", "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", "fields": { "config_entry": { @@ -54,13 +54,13 @@ "description": "[%key:component::screenlogic::common::service_config_entry_description%]" }, "color_mode": { - "name": "Color Mode", + "name": "Color mode", "description": "The ScreenLogic color mode to set." } } }, "start_super_chlorination": { - "name": "Start Super Chlorination", + "name": "Start super chlorination", "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", "fields": { "config_entry": { @@ -68,13 +68,13 @@ "description": "[%key:component::screenlogic::common::service_config_entry_description%]" }, "runtime": { - "name": "Run Time", + "name": "Run time", "description": "Number of hours for super chlorination to run." } } }, "stop_super_chlorination": { - "name": "Stop Super Chlorination", + "name": "Stop super chlorination", "description": "Stops super chlorination.", "fields": { "config_entry": { diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 1d36ee00b94..dfbb1c1781d 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -8,7 +8,7 @@ from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LIGHT_CIRCUIT_FUNCTIONS from .entity import ( @@ -29,7 +29,7 @@ class ScreenLogicCircuitSwitchDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" entities: list[ScreenLogicSwitchingEntity] = [] diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index c0d79c446bb..dd293726484 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api @@ -39,7 +39,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py index 9aabb315942..636c157b076 100644 --- a/homeassistant/components/scsgate/__init__.py +++ b/homeassistant/components/scsgate/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index b6d3317555c..4c4d2c2949a 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py index 23b73a0fd6b..0addbda9e09 100644 --- a/homeassistant/components/scsgate/light.py +++ b/homeassistant/components/scsgate/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index abc906a5533..4607d65ac7a 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 96744db1d02..bdc24883c90 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import DOMAIN, TYPE_ASTRONOMICAL @@ -37,7 +37,7 @@ HEMISPHERE_SEASON_SWAP = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config entry.""" hemisphere = EQUATOR diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 3834dc4a0c7..4196106edd2 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -45,15 +45,15 @@ __all__ = [ "ATTR_OPTION", "ATTR_OPTIONS", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", - "SelectEntity", - "SelectEntityDescription", + "PLATFORM_SCHEMA_BASE", "SERVICE_SELECT_FIRST", "SERVICE_SELECT_LAST", "SERVICE_SELECT_NEXT", "SERVICE_SELECT_OPTION", "SERVICE_SELECT_PREVIOUS", + "SelectEntity", + "SelectEntityDescription", ] # mypy: disallow-any-generics diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index a3827a23d41..1801d34d182 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 86f01804574..4dbb95085cb 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index e919d48e96d..a5393181057 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -89,8 +89,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo except SENSE_WEBSOCKET_EXCEPTIONS as err: raise ConfigEntryNotReady(str(err) or "Error during realtime update") from err - trends_coordinator = SenseTrendCoordinator(hass, gateway) - realtime_coordinator = SenseRealtimeCoordinator(hass, gateway) + trends_coordinator = SenseTrendCoordinator(hass, entry, gateway) + realtime_coordinator = SenseRealtimeCoordinator(hass, entry, gateway) # This can take longer than 60s and we already know # sense is online since get_discovered_device_data was diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index d06b3a62937..3bb8a32b8e4 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SenseConfigEntry from .const import DOMAIN @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: SenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sense binary sensor.""" sense_monitor_id = config_entry.runtime_data.data.sense_monitor_id diff --git a/homeassistant/components/sense/coordinator.py b/homeassistant/components/sense/coordinator.py index c0029cd79ea..1957352aea6 100644 --- a/homeassistant/components/sense/coordinator.py +++ b/homeassistant/components/sense/coordinator.py @@ -1,7 +1,10 @@ """Sense Coordinators.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import TYPE_CHECKING from sense_energy import ( ASyncSenseable, @@ -13,6 +16,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import SenseConfigEntry + from .const import ( ACTIVE_UPDATE_RATE, SENSE_CONNECT_EXCEPTIONS, @@ -27,13 +33,21 @@ _LOGGER = logging.getLogger(__name__) class SenseCoordinator(DataUpdateCoordinator[None]): """Sense Trend Coordinator.""" + config_entry: SenseConfigEntry + def __init__( - self, hass: HomeAssistant, gateway: ASyncSenseable, name: str, update: int + self, + hass: HomeAssistant, + config_entry: SenseConfigEntry, + gateway: ASyncSenseable, + name: str, + update: int, ) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=f"Sense {name} {gateway.sense_monitor_id}", update_interval=timedelta(seconds=update), ) @@ -44,9 +58,14 @@ class SenseCoordinator(DataUpdateCoordinator[None]): class SenseTrendCoordinator(SenseCoordinator): """Sense Trend Coordinator.""" - def __init__(self, hass: HomeAssistant, gateway: ASyncSenseable) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SenseConfigEntry, + gateway: ASyncSenseable, + ) -> None: """Initialize.""" - super().__init__(hass, gateway, "Trends", TREND_UPDATE_RATE) + super().__init__(hass, config_entry, gateway, "Trends", TREND_UPDATE_RATE) async def _async_update_data(self) -> None: """Update the trend data.""" @@ -62,9 +81,14 @@ class SenseTrendCoordinator(SenseCoordinator): class SenseRealtimeCoordinator(SenseCoordinator): """Sense Realtime Coordinator.""" - def __init__(self, hass: HomeAssistant, gateway: ASyncSenseable) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SenseConfigEntry, + gateway: ASyncSenseable, + ) -> None: """Initialize.""" - super().__init__(hass, gateway, "Realtime", ACTIVE_UPDATE_RATE) + super().__init__(hass, config_entry, gateway, "Realtime", ACTIVE_UPDATE_RATE) async def _async_update_data(self) -> None: """Retrieve latest state.""" diff --git a/homeassistant/components/sense/entity.py b/homeassistant/components/sense/entity.py index 248be53ceb7..35c556a51f2 100644 --- a/homeassistant/components/sense/entity.py +++ b/homeassistant/components/sense/entity.py @@ -12,7 +12,7 @@ from .coordinator import SenseCoordinator def sense_to_mdi(sense_icon: str) -> str: """Convert sense icon to mdi icon.""" - return f"mdi:{MDI_ICONS.get(sense_icon, "power-plug")}" + return f"mdi:{MDI_ICONS.get(sense_icon, 'power-plug')}" class SenseEntity(CoordinatorEntity[SenseCoordinator]): diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 966488b6a48..a7cee28f9c9 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.4"] + "requirements": ["sense-energy==0.13.5"] } diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 2f5c82675d5..8cb4bdd3e56 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SenseConfigEntry from .const import ( @@ -66,7 +66,7 @@ TREND_SENSOR_VARIANTS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: SenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sense sensor.""" data = config_entry.runtime_data.data diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 8d47fb11526..c7116db7954 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -15,9 +16,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry +from .const import LOGGER from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity @@ -116,38 +118,61 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SENSOR_TYPES} async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo binary sensor platform.""" coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, remove_devices, new_added_devices = coordinator.get_devices( + added_devices + ) + added_devices = new_added_devices + + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug( + "New devices: %s, Removed devices: %s, Existing devices: %s", + new_devices, + remove_devices, + added_devices, + ) + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for description in MOTION_DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if device_data.motion_sensors - ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors and device_id in new_devices + for description in MOTION_DEVICE_SENSOR_TYPES + ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index 7adafe2e7fc..d36967dae06 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator @@ -35,16 +35,29 @@ DEVICE_BUTTON_TYPES = SensiboButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo button platform.""" coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) - for device_id, device_data in coordinator.data.parsed.items() - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: + async_add_entities( + SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index b35cb8af9a9..906c4259ce5 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from bisect import bisect_left -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from . import SensiboConfigEntry @@ -138,18 +138,29 @@ def _find_valid_target_temp(target: float, valid_targets: list[int]) -> int: async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensibo climate entry.""" coordinator = entry.runtime_data - entities = [ - SensiboClimate(coordinator, device_id) - for device_id, device_data in coordinator.data.parsed.items() - ] + added_devices: set[str] = set() - async_add_entities(entities) + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: + async_add_entities( + SensiboClimate(coordinator, device_id) + for device_id in coordinator.data.parsed + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -199,7 +210,7 @@ async def async_setup_entry( vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, vol.Required(ATTR_SMART_TYPE): vol.In( - ["temperature", "feelsLike", "humidity"] + ["temperature", "feelslike", "humidity"] ), }, "async_enable_climate_react", @@ -255,8 +266,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" - if not self.device_data.hvac_modes: - return [HVACMode.OFF] + if TYPE_CHECKING: + assert self.device_data.hvac_modes return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] @property diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e512935dfce..3fa8a6e5dae 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -12,6 +12,7 @@ from pysensibo.model import SensiboData from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -48,6 +49,38 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): session=async_get_clientsession(hass), timeout=TIMEOUT, ) + self.previous_devices: set[str] = set() + + def get_devices( + self, added_devices: set[str] + ) -> tuple[set[str], set[str], set[str]]: + """Addition and removal of devices.""" + data = self.data + current_motion_sensors = { + sensor_id + for device_data in data.parsed.values() + if device_data.motion_sensors + for sensor_id in device_data.motion_sensors + } + current_devices: set[str] = set(data.parsed) + LOGGER.debug( + "Current devices: %s, moption sensors: %s", + current_devices, + current_motion_sensors, + ) + new_devices: set[str] = ( + current_motion_sensors | current_devices + ) - added_devices + remove_devices = added_devices - current_devices - current_motion_sensors + new_added_devices = (added_devices - remove_devices) | new_devices + + LOGGER.debug( + "New devices: %s, Removed devices: %s, Added devices: %s", + new_devices, + remove_devices, + new_added_devices, + ) + return (new_devices, remove_devices, new_added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" @@ -67,4 +100,23 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): if not data.raw: raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data") + + current_devices = set(data.parsed) + for device_data in data.parsed.values(): + if device_data.motion_sensors: + for motion_sensor_id in device_data.motion_sensors: + current_devices.add(motion_sensor_id) + + if stale_devices := self.previous_devices - current_devices: + LOGGER.debug("Removing stale devices: %s", stale_devices) + device_registry = dr.async_get(self.hass) + for _id in stale_devices: + device = device_registry.async_get_device(identifiers={(DOMAIN, _id)}) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + self.previous_devices = current_devices + return data diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index baa056f0eea..e71ed6f0235 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator @@ -65,17 +65,30 @@ DEVICE_NUMBER_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo number platform.""" coordinator = entry.runtime_data - async_add_entities( - SensiboNumber(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_NUMBER_TYPES - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: + async_add_entities( + SensiboNumber(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in DEVICE_NUMBER_TYPES + if device_id in new_devices + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index 08632ddac0f..c21cf100e9d 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -54,7 +54,7 @@ rules: entity-category: done entity-disabled-by-default: done discovery: done - stale-devices: todo + stale-devices: done diagnostics: status: done comment: | @@ -62,7 +62,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - dynamic-devices: todo + dynamic-devices: done discovery-update-info: status: exempt comment: | diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 12e7364d6ee..5a0546b1aa2 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -16,9 +16,8 @@ from homeassistant.components.select import ( SelectEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -68,7 +67,7 @@ DEVICE_SELECT_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo select platform.""" @@ -109,17 +108,28 @@ async def async_setup_entry( "entity": entity_id, }, ) - - entities.extend( - [ - SensiboSelect(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DEVICE_SELECT_TYPES - if description.key in device_data.full_features - ] - ) async_add_entities(entities) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: + async_add_entities( + SensiboSelect(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SELECT_TYPES + if description.key in device_data.full_features + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() + class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): """Representation of a Sensibo Select.""" @@ -137,6 +147,13 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.key not in self.device_data.active_features: + return False + return super().available + @property def current_option(self) -> str | None: """Return the current selected option.""" @@ -152,17 +169,6 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" - if self.entity_description.key not in self.device_data.active_features: - hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="select_option_not_available", - translation_placeholders={ - "hvac_mode": hvac_mode, - "key": self.entity_description.key, - }, - ) - await self.async_send_api_call( key=self.entity_description.data_key, value=option, diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index b395f8eb1ee..09f095bfaec 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import SensiboConfigEntry @@ -36,6 +36,13 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 +def _smart_type_name(_type: str | None) -> str | None: + """Return a lowercase name of smart type.""" + if _type and _type == "feelsLike": + return "feelslike" + return _type + + @dataclass(frozen=True, kw_only=True) class SensiboMotionSensorEntityDescription(SensorEntityDescription): """Describes Sensibo Motion sensor entity.""" @@ -153,7 +160,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="climate_react_type", translation_key="smart_type", - value_fn=lambda data: data.smart_type, + value_fn=lambda data: _smart_type_name(data.smart_type), extra_fn=None, entity_registry_enabled_default=False, ), @@ -233,31 +240,45 @@ DESCRIPTION_BY_MODELS = { async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo sensor platform.""" coordinator = entry.runtime_data - entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + added_devices: set[str] = set() - for device_id, device_data in coordinator.data.parsed.items(): - if device_data.motion_sensors: + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + + entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: entities.extend( SensiboMotionSensor( coordinator, device_id, sensor_id, sensor_data, description ) + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors for sensor_id, sensor_data in device_data.motion_sensors.items() + if sensor_id in new_devices for description in MOTION_SENSOR_TYPES ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SENSOR_TYPES - ) - ) - async_add_entities(entities) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SENSOR_TYPES + ) + ) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index a1f60c247a3..6aba2be52fc 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -18,7 +18,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the [documentation]({url}) to get your api key" + "api_key": "Follow the [documentation]({url}) to get your API key" } }, "reauth_confirm": { @@ -429,16 +429,16 @@ } }, "enable_pure_boost": { - "name": "Enable pure boost", + "name": "Enable Pure Boost", "description": "Enables and configures Pure Boost settings.", "fields": { "ac_integration": { "name": "AC integration", - "description": "Integrate with Air Conditioner." + "description": "Integrate with air conditioner." }, "geo_integration": { "name": "Geo integration", - "description": "Integrate with Presence." + "description": "Integrate with presence." }, "indoor_integration": { "name": "Indoor air quality", @@ -468,7 +468,7 @@ }, "fan_mode": { "name": "Fan mode", - "description": "set fan mode." + "description": "Set fan mode." }, "swing_mode": { "name": "Swing mode", @@ -485,8 +485,8 @@ } }, "enable_climate_react": { - "name": "Enable climate react", - "description": "Enables and configures climate react.", + "name": "Enable Climate React", + "description": "Enables and configures Climate React.", "fields": { "high_temperature_threshold": { "name": "Threshold high", @@ -512,7 +512,7 @@ }, "get_device_capabilities": { "name": "Get device mode capabilities", - "description": "Retrieve the device capabilities for a specific device according to api requirements.", + "description": "Retrieves the device capabilities for a specific device according to API requirements.", "fields": { "hvac_mode": { "name": "[%key:component::climate::services::set_hvac_mode::fields::hvac_mode::name%]", @@ -575,11 +575,8 @@ "service_raised": { "message": "Could not perform action for {name} with error {error}" }, - "select_option_not_available": { - "message": "Current mode {hvac_mode} doesn't support setting {key}" - }, "climate_react_not_available": { - "message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app" + "message": "Use the Sensibo 'Enable Climate React' action once to enable the switch, or use the Sensibo app" }, "auth_error": { "message": "Authentication failed, please update your API key" diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 46906ac1871..03e7c12ec2b 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .const import DOMAIN @@ -78,19 +78,32 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SWITCH_TYPES} async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo Switch platform.""" coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceSwitch(coordinator, device_id, description) - for device_id, device_data in coordinator.data.parsed.items() - for description in DESCRIPTION_BY_MODELS.get( - device_data.model, DEVICE_SWITCH_TYPES - ) - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: + async_add_entities( + SensiboDeviceSwitch(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DESCRIPTION_BY_MODELS.get( + device_data.model, DEVICE_SWITCH_TYPES + ) + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index d52565564a6..6f868e5f366 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -14,7 +14,7 @@ from homeassistant.components.update import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator @@ -45,18 +45,31 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo Update platform.""" coordinator = entry.runtime_data - async_add_entities( - SensiboDeviceUpdate(coordinator, device_id, description) - for description in DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if description.value_available(device_data) is not None - ) + added_devices: set[str] = set() + + def _add_remove_devices() -> None: + """Handle additions of devices and sensors.""" + nonlocal added_devices + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices + + if new_devices: + async_add_entities( + SensiboDeviceUpdate(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + if device_id in new_devices + for description in DEVICE_SENSOR_TYPES + if description.value_available(device_data) is not None + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) + _add_remove_devices() class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity): diff --git a/homeassistant/components/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py index a7254fd3609..16f7571f392 100644 --- a/homeassistant/components/sensirion_ble/sensor.py +++ b/homeassistant/components/sensirion_ble/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -106,7 +106,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensirion BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2933d779b4b..89f39d4fb8c 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -12,7 +12,7 @@ import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final, override -from propcache import cached_property +from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 @@ -67,8 +67,8 @@ __all__ = [ "CONF_STATE_CLASS", "DEVICE_CLASS_STATE_CLASSES", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "RestoreSensor", "SensorDeviceClass", "SensorEntity", diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8c3c3925513..8eccb758756 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + DEGREE, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -23,6 +24,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -51,6 +53,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -194,6 +197,15 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -392,7 +404,7 @@ class SensorDeviceClass(StrEnum): VOLTAGE = "voltage" """Voltage. - Unit of measurement: `V`, `mV`, `µV` + Unit of measurement: `V`, `mV`, `µV`, `kV`, `MV` """ VOLUME = "volume" @@ -443,6 +455,12 @@ class SensorDeviceClass(StrEnum): - USCS / imperial: `oz`, `lb` """ + WIND_DIRECTION = "wind_direction" + """Wind direction. + + Unit of measurement: `°` + """ + WIND_SPEED = "wind_speed" """Wind speed. @@ -500,6 +518,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, @@ -541,6 +560,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), + SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { @@ -577,7 +597,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SIGNAL_STRENGTH_DECIBELS_MILLIWATT, }, SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure), - SensorDeviceClass.SPEED: set(UnitOfSpeed).union(set(UnitOfVolumetricFlux)), + SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux}, SensorDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { @@ -599,6 +619,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.LITERS, }, SensorDeviceClass.WEIGHT: set(UnitOfMass), + SensorDeviceClass.WIND_DIRECTION: {DEGREE}, SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed), } @@ -622,6 +643,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_DISTANCE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, @@ -669,5 +691,6 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.WIND_DIRECTION: set(), SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index fc25dce18fc..f52393f28ff 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -48,6 +48,7 @@ CONF_IS_DATA_SIZE = "is_data_size" CONF_IS_DISTANCE = "is_distance" CONF_IS_DURATION = "is_duration" CONF_IS_ENERGY = "is_energy" +CONF_IS_ENERGY_DISTANCE = "is_energy_distance" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" CONF_IS_GAS = "is_gas" @@ -82,6 +83,7 @@ CONF_IS_VOLUME = "is_volume" CONF_IS_VOLUME_FLOW_RATE = "is_volume_flow_rate" CONF_IS_WATER = "is_water" CONF_IS_WEIGHT = "is_weight" +CONF_IS_WIND_DIRECTION = "is_wind_direction" CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { @@ -102,6 +104,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_IS_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], @@ -143,6 +146,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_IS_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], + SensorDeviceClass.WIND_DIRECTION: [{CONF_TYPE: CONF_IS_WIND_DIRECTION}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], } @@ -168,6 +172,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_DISTANCE, CONF_IS_DURATION, CONF_IS_ENERGY, + CONF_IS_ENERGY_DISTANCE, CONF_IS_FREQUENCY, CONF_IS_GAS, CONF_IS_HUMIDITY, @@ -201,6 +206,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_VOLUME_FLOW_RATE, CONF_IS_WATER, CONF_IS_WEIGHT, + CONF_IS_WIND_DIRECTION, CONF_IS_WIND_SPEED, CONF_IS_VALUE, ] diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d75b3aa6e41..dee48434294 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -47,6 +47,7 @@ CONF_DATA_SIZE = "data_size" CONF_DISTANCE = "distance" CONF_DURATION = "duration" CONF_ENERGY = "energy" +CONF_ENERGY_DISTANCE = "energy_distance" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" CONF_HUMIDITY = "humidity" @@ -81,6 +82,7 @@ CONF_VOLUME = "volume" CONF_VOLUME_FLOW_RATE = "volume_flow_rate" CONF_WATER = "water" CONF_WEIGHT = "weight" +CONF_WIND_DIRECTION = "wind_direction" CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { @@ -101,6 +103,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], @@ -142,6 +145,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], + SensorDeviceClass.WIND_DIRECTION: [{CONF_TYPE: CONF_WIND_DIRECTION}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], } @@ -168,6 +172,7 @@ TRIGGER_SCHEMA = vol.All( CONF_DISTANCE, CONF_DURATION, CONF_ENERGY, + CONF_ENERGY_DISTANCE, CONF_FREQUENCY, CONF_GAS, CONF_HUMIDITY, @@ -201,6 +206,7 @@ TRIGGER_SCHEMA = vol.All( CONF_VOLUME_FLOW_RATE, CONF_WATER, CONF_WEIGHT, + CONF_WIND_DIRECTION, CONF_WIND_SPEED, CONF_VALUE, ] diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 5f770765ee3..497c1544b3b 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -156,6 +156,9 @@ "weight": { "default": "mdi:weight" }, + "wind_direction": { + "default": "mdi:compass-rose" + }, "wind_speed": { "default": "mdi:weather-windy" } diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d44d621f82d..ae414a178e9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -17,6 +17,7 @@ "is_distance": "Current {entity_name} distance", "is_duration": "Current {entity_name} duration", "is_energy": "Current {entity_name} energy", + "is_energy_distance": "Current {entity_name} energy per distance", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", @@ -51,6 +52,7 @@ "is_volume_flow_rate": "Current {entity_name} volume flow rate", "is_water": "Current {entity_name} water", "is_weight": "Current {entity_name} weight", + "is_wind_direction": "Current {entity_name} wind direction", "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { @@ -69,6 +71,7 @@ "distance": "{entity_name} distance changes", "duration": "{entity_name} duration changes", "energy": "{entity_name} energy changes", + "energy_distance": "{entity_name} energy per distance changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", @@ -103,6 +106,7 @@ "volume_flow_rate": "{entity_name} volume flow rate changes", "water": "{entity_name} water changes", "weight": "{entity_name} weight changes", + "wind_direction": "{entity_name} wind direction changes", "wind_speed": "{entity_name} wind speed changes" }, "extra_fields": { @@ -183,6 +187,9 @@ "energy": { "name": "Energy" }, + "energy_distance": { + "name": "Energy per distance" + }, "energy_storage": { "name": "Stored energy" }, @@ -294,6 +301,9 @@ "weight": { "name": "Weight" }, + "wind_direction": { + "name": "Wind direction" + }, "wind_speed": { "name": "Wind speed" } diff --git a/homeassistant/components/sensorpro/sensor.py b/homeassistant/components/sensorpro/sensor.py index b972aac04fb..997fa0db995 100644 --- a/homeassistant/components/sensorpro/sensor.py +++ b/homeassistant/components/sensorpro/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -111,7 +111,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SensorPro BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/sensorpush/config_flow.py b/homeassistant/components/sensorpush/config_flow.py index d826029276b..d3233ac2d5f 100644 --- a/homeassistant/components/sensorpush/config_flow.py +++ b/homeassistant/components/sensorpush/config_flow.py @@ -72,7 +72,7 @@ class SensorPushConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 6eea5c10f78..730277350b5 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import SensorPushConfigEntry @@ -97,7 +97,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: SensorPushConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SensorPush BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sensorpush_cloud/__init__.py b/homeassistant/components/sensorpush_cloud/__init__.py new file mode 100644 index 00000000000..2d9d299c132 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/__init__.py @@ -0,0 +1,28 @@ +"""The SensorPush Cloud integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: SensorPushCloudConfigEntry +) -> bool: + """Set up SensorPush Cloud from a config entry.""" + coordinator = SensorPushCloudCoordinator(hass, entry) + entry.runtime_data = coordinator + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SensorPushCloudConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sensorpush_cloud/config_flow.py b/homeassistant/components/sensorpush_cloud/config_flow.py new file mode 100644 index 00000000000..d06fde2eba1 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow for the SensorPush Cloud integration.""" + +from __future__ import annotations + +from typing import Any + +from sensorpush_ha import SensorPushCloudApi, SensorPushCloudAuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + + +class SensorPushCloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for SensorPush Cloud.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + email, password = user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + await self.async_set_unique_id(email) + self._abort_if_unique_id_configured() + clientsession = async_get_clientsession(self.hass) + api = SensorPushCloudApi(email, password, clientsession) + try: + await api.async_authorize() + except SensorPushCloudAuthError: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=email, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, autocomplete="username" + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sensorpush_cloud/const.py b/homeassistant/components/sensorpush_cloud/const.py new file mode 100644 index 00000000000..9e66dacfaba --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/const.py @@ -0,0 +1,12 @@ +"""Constants for the SensorPush Cloud integration.""" + +from datetime import timedelta +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "sensorpush_cloud" + +UPDATE_INTERVAL: Final = timedelta(seconds=60) +MAX_TIME_BETWEEN_UPDATES: Final = UPDATE_INTERVAL * 60 diff --git a/homeassistant/components/sensorpush_cloud/coordinator.py b/homeassistant/components/sensorpush_cloud/coordinator.py new file mode 100644 index 00000000000..9885538b55a --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/coordinator.py @@ -0,0 +1,45 @@ +"""Coordinator for the SensorPush Cloud integration.""" + +from __future__ import annotations + +from sensorpush_ha import ( + SensorPushCloudApi, + SensorPushCloudData, + SensorPushCloudError, + SensorPushCloudHelper, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, UPDATE_INTERVAL + +type SensorPushCloudConfigEntry = ConfigEntry[SensorPushCloudCoordinator] + + +class SensorPushCloudCoordinator(DataUpdateCoordinator[dict[str, SensorPushCloudData]]): + """SensorPush Cloud coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: SensorPushCloudConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=entry.title, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + email, password = entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD] + clientsession = async_get_clientsession(hass) + api = SensorPushCloudApi(email, password, clientsession) + self.helper = SensorPushCloudHelper(api) + + async def _async_update_data(self) -> dict[str, SensorPushCloudData]: + """Fetch data from API endpoints.""" + try: + return await self.helper.async_get_data() + except SensorPushCloudError as e: + raise UpdateFailed(e) from e diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json new file mode 100644 index 00000000000..ad817251fa1 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "sensorpush_cloud", + "name": "SensorPush Cloud", + "codeowners": ["@sstallion"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sensorpush_cloud", + "iot_class": "cloud_polling", + "loggers": ["sensorpush_api", "sensorpush_ha"], + "quality_scale": "bronze", + "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] +} diff --git a/homeassistant/components/sensorpush_cloud/quality_scale.yaml b/homeassistant/components/sensorpush_cloud/quality_scale.yaml new file mode 100644 index 00000000000..96816e1d50d --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not support options flow. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/sensorpush_cloud/sensor.py b/homeassistant/components/sensorpush_cloud/sensor.py new file mode 100644 index 00000000000..d2855f63a62 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/sensor.py @@ -0,0 +1,158 @@ +"""Support for SensorPush Cloud sensors.""" + +from __future__ import annotations + +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfElectricPotential, + UnitOfLength, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, MAX_TIME_BETWEEN_UPDATES +from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator + +ATTR_ALTITUDE: Final = "altitude" +ATTR_ATMOSPHERIC_PRESSURE: Final = "atmospheric_pressure" +ATTR_BATTERY_VOLTAGE: Final = "battery_voltage" +ATTR_DEWPOINT: Final = "dewpoint" +ATTR_HUMIDITY: Final = "humidity" +ATTR_SIGNAL_STRENGTH: Final = "signal_strength" +ATTR_VAPOR_PRESSURE: Final = "vapor_pressure" + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key=ATTR_ALTITUDE, + device_class=SensorDeviceClass.DISTANCE, + entity_registry_enabled_default=False, + translation_key="altitude", + native_unit_of_measurement=UnitOfLength.FEET, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_ATMOSPHERIC_PRESSURE, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPressure.INHG, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_DEWPOINT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + translation_key="dewpoint", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_VAPOR_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + entity_registry_enabled_default=False, + translation_key="vapor_pressure", + native_unit_of_measurement=UnitOfPressure.KPA, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SensorPushCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SensorPush Cloud sensors.""" + coordinator = entry.runtime_data + async_add_entities( + SensorPushCloudSensor(coordinator, entity_description, device_id) + for entity_description in SENSORS + for device_id in coordinator.data + ) + + +class SensorPushCloudSensor( + CoordinatorEntity[SensorPushCloudCoordinator], SensorEntity +): + """SensorPush Cloud sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SensorPushCloudCoordinator, + entity_description: SensorEntityDescription, + device_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self.device_id = device_id + + device = coordinator.data[device_id] + self._attr_unique_id = f"{device.device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer=device.manufacturer, + model=device.model, + name=device.name, + ) + + @property + def available(self) -> bool: + """Return true if entity is available.""" + if self.device_id in self.coordinator.data: + last_update = self.coordinator.data[self.device_id].last_update + if dt_util.utcnow() >= (last_update + MAX_TIME_BETWEEN_UPDATES): + return False + return super().available + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self.device_id][self.entity_description.key] diff --git a/homeassistant/components/sensorpush_cloud/strings.json b/homeassistant/components/sensorpush_cloud/strings.json new file mode 100644 index 00000000000..8467a123b6f --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "description": "To activate API access, log in to the [Gateway Cloud Dashboard](https://dashboard.sensorpush.com/) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to log in to the SensorPush Gateway Cloud Dashboard", + "password": "The password used to log in to the SensorPush Gateway Cloud Dashboard" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "altitude": { + "name": "Altitude" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "dewpoint": { + "name": "Dew point" + }, + "vapor_pressure": { + "name": "Vapor pressure" + } + } + } +} diff --git a/homeassistant/components/sensoterra/__init__.py b/homeassistant/components/sensoterra/__init__.py index b1428351f09..1559dc10c43 100644 --- a/homeassistant/components/sensoterra/__init__.py +++ b/homeassistant/components/sensoterra/__init__.py @@ -4,16 +4,13 @@ from __future__ import annotations from sensoterra.customerapi import CustomerApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .coordinator import SensoterraCoordinator +from .coordinator import SensoterraConfigEntry, SensoterraCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type SensoterraConfigEntry = ConfigEntry[SensoterraCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) -> bool: """Set up Sensoterra platform based on a configuration entry.""" @@ -24,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) - api.set_language(hass.config.language) api.set_token(entry.data[CONF_TOKEN]) - coordinator = SensoterraCoordinator(hass, api) + coordinator = SensoterraCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/sensoterra/coordinator.py b/homeassistant/components/sensoterra/coordinator.py index 2dffdceb443..9020633a2a3 100644 --- a/homeassistant/components/sensoterra/coordinator.py +++ b/homeassistant/components/sensoterra/coordinator.py @@ -10,21 +10,29 @@ from sensoterra.customerapi import ( ) from sensoterra.probe import Probe, Sensor +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER, SCAN_INTERVAL_MINUTES +type SensoterraConfigEntry = ConfigEntry[SensoterraCoordinator] + class SensoterraCoordinator(DataUpdateCoordinator[list[Probe]]): """Sensoterra coordinator.""" - def __init__(self, hass: HomeAssistant, api: CustomerApi) -> None: + config_entry: SensoterraConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: SensoterraConfigEntry, api: CustomerApi + ) -> None: """Initialize Sensoterra coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Sensoterra probe", update_interval=timedelta(minutes=SCAN_INTERVAL_MINUTES), ) diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py index 7e9f4d0840e..56f47ade212 100644 --- a/homeassistant/components/sensoterra/sensor.py +++ b/homeassistant/components/sensoterra/sensor.py @@ -21,13 +21,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SensoterraConfigEntry from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS -from .coordinator import SensoterraCoordinator +from .coordinator import SensoterraConfigEntry, SensoterraCoordinator class ProbeSensorType(StrEnum): @@ -85,7 +84,7 @@ SENSORS: dict[ProbeSensorType, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SensoterraConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensoterra sensor.""" diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 425225e07ef..4c3a7518085 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.40.3"] + "requirements": ["sentry-sdk==1.45.1"] } diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index d5749a3f040..48eeee54974 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SENZDataUpdateCoordinator @@ -26,7 +26,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SENZ climate entities from a config entry.""" coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index a09401473b2..4d43408397f 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index b454424591d..570d1ac0d63 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index ad8b26f7034..5165d3d4798 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -13,7 +13,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index 63fd27e0dd0..bda17b75081 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 695ca179966..235a5338cb6 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SeventeenTrackError as err: raise ConfigEntryNotReady from err - seventeen_coordinator = SeventeenTrackCoordinator(hass, client) + seventeen_coordinator = SeventeenTrackCoordinator(hass, entry, client) await seventeen_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 6b888590600..19e2d3083c9 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -47,6 +47,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_CONFIG_ENTRY_ID = "config_entry_id" - - -DEPRECATED_KEY = "deprecated" diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py index 3e27f9f0369..107f1d48a21 100644 --- a/homeassistant/components/seventeentrack/coordinator.py +++ b/homeassistant/components/seventeentrack/coordinator.py @@ -34,11 +34,17 @@ class SeventeenTrackCoordinator(DataUpdateCoordinator[SeventeenTrackData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, client: SeventeenTrackClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: SeventeenTrackClient, + ) -> None: """Initialize.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json index a5cac0a9f84..c48e147e973 100644 --- a/homeassistant/components/seventeentrack/icons.json +++ b/homeassistant/components/seventeentrack/icons.json @@ -19,7 +19,7 @@ "delivered": { "default": "mdi:package" }, - "returned": { + "alert": { "default": "mdi:package" }, "package": { diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index a130fbe9aee..34019208a14 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.1"] + "requirements": ["pyseventeentrack==1.0.2"] } diff --git a/homeassistant/components/seventeentrack/repairs.py b/homeassistant/components/seventeentrack/repairs.py deleted file mode 100644 index ce72960ea91..00000000000 --- a/homeassistant/components/seventeentrack/repairs.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Repairs for the SeventeenTrack integration.""" - -import voluptuous as vol - -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult - -from .const import DEPRECATED_KEY - - -class SensorDeprecationRepairFlow(RepairsFlow): - """Handler for an issue fixing flow.""" - - def __init__(self, entry: ConfigEntry) -> None: - """Create flow.""" - self.entry = entry - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - data = {**self.entry.data, DEPRECATED_KEY: True} - self.hass.config_entries.async_update_entry(self.entry, data=data) - return self.async_create_entry(data={}) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, issue_id: str, data: dict -) -> RepairsFlow: - """Create flow.""" - if issue_id.startswith("deprecate_sensor_") and ( - entry := hass.config_entries.async_get_entry(data["entry_id"]) - ): - return SensorDeprecationRepairFlow(entry) - return ConfirmRepairFlow() diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 4e561a87961..b0f9d6cd2bd 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -4,104 +4,41 @@ from __future__ import annotations from typing import Any -from homeassistant.components import persistent_notification from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator from .const import ( - ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, - ATTR_ORIGIN_COUNTRY, - ATTR_PACKAGE_TYPE, ATTR_PACKAGES, ATTR_STATUS, ATTR_TIMESTAMP, - ATTR_TRACKING_INFO_LANGUAGE, ATTR_TRACKING_NUMBER, ATTRIBUTION, - DEPRECATED_KEY, DOMAIN, - LOGGER, - NOTIFICATION_DELIVERED_MESSAGE, - NOTIFICATION_DELIVERED_TITLE, - UNIQUE_ID_TEMPLATE, - VALUE_DELIVERED, ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a 17Track sensor entry.""" coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id] - previous_tracking_numbers: set[str] = set() - - # This has been deprecated in 2024.8, will be removed in 2025.2 - @callback - def _async_create_remove_entities(): - if config_entry.data.get(DEPRECATED_KEY): - remove_packages(hass, coordinator.account_id, previous_tracking_numbers) - return - live_tracking_numbers = set(coordinator.data.live_packages.keys()) - - new_tracking_numbers = live_tracking_numbers - previous_tracking_numbers - old_tracking_numbers = previous_tracking_numbers - live_tracking_numbers - - previous_tracking_numbers.update(live_tracking_numbers) - - packages_to_add = [ - coordinator.data.live_packages[tracking_number] - for tracking_number in new_tracking_numbers - ] - - for package_data in coordinator.data.live_packages.values(): - if ( - package_data.status == VALUE_DELIVERED - and not coordinator.show_delivered - ): - old_tracking_numbers.add(package_data.tracking_number) - notify_delivered( - hass, - package_data.friendly_name, - package_data.tracking_number, - ) - - remove_packages(hass, coordinator.account_id, old_tracking_numbers) - - async_add_entities( - SeventeenTrackPackageSensor( - coordinator, - package_data.tracking_number, - ) - for package_data in packages_to_add - if not ( - not coordinator.show_delivered and package_data.status == "Delivered" - ) - ) async_add_entities( SeventeenTrackSummarySensor(status, coordinator) for status, summary_data in coordinator.data.summary.items() ) - if not config_entry.data.get(DEPRECATED_KEY): - deprecate_sensor_issue(hass, config_entry.entry_id) - _async_create_remove_entities() - config_entry.async_on_unload( - coordinator.async_add_listener(_async_create_remove_entities) - ) - class SeventeenTrackSensor(CoordinatorEntity[SeventeenTrackCoordinator], SensorEntity): """Define a 17Track sensor.""" @@ -163,96 +100,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor): for package in packages ] } - - -# The dynamic package sensors have been replaced by the seventeentrack.get_packages service -class SeventeenTrackPackageSensor(SeventeenTrackSensor): - """Define an individual package sensor.""" - - _attr_translation_key = "package" - - def __init__( - self, - coordinator: SeventeenTrackCoordinator, - tracking_number: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._tracking_number = tracking_number - self._previous_status = coordinator.data.live_packages[tracking_number].status - self._attr_unique_id = UNIQUE_ID_TEMPLATE.format( - coordinator.account_id, tracking_number - ) - package = coordinator.data.live_packages[tracking_number] - if not (name := package.friendly_name): - name = tracking_number - self._attr_translation_placeholders = {"name": name} - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self._tracking_number in self.coordinator.data.live_packages - - @property - def native_value(self) -> StateType: - """Return the state.""" - return self.coordinator.data.live_packages[self._tracking_number].status - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - package = self.coordinator.data.live_packages[self._tracking_number] - return { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_INFO_TEXT: package.info_text, - ATTR_TIMESTAMP: package.timestamp, - ATTR_LOCATION: package.location, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - } - - -def remove_packages(hass: HomeAssistant, account_id: str, packages: set[str]) -> None: - """Remove entity itself.""" - reg = er.async_get(hass) - for package in packages: - entity_id = reg.async_get_entity_id( - "sensor", - "seventeentrack", - UNIQUE_ID_TEMPLATE.format(account_id, package), - ) - if entity_id: - reg.async_remove(entity_id) - - -def notify_delivered(hass: HomeAssistant, friendly_name: str, tracking_number: str): - """Notify when package is delivered.""" - LOGGER.debug("Package delivered: %s", tracking_number) - - identification = friendly_name if friendly_name else tracking_number - message = NOTIFICATION_DELIVERED_MESSAGE.format(identification, tracking_number) - title = NOTIFICATION_DELIVERED_TITLE.format(identification) - notification_id = NOTIFICATION_DELIVERED_TITLE.format(tracking_number) - - persistent_notification.create( - hass, message, title=title, notification_id=notification_id - ) - - -@callback -def deprecate_sensor_issue(hass: HomeAssistant, entry_id: str) -> None: - """Ensure an issue is registered.""" - ir.async_create_issue( - hass, - DOMAIN, - f"deprecate_sensor_{entry_id}", - breaks_in_ha_version="2025.2.0", - issue_domain=DOMAIN, - is_fixable=True, - is_persistent=True, - translation_key="deprecate_sensor", - severity=ir.IssueSeverity.WARNING, - data={"entry_id": entry_id}, - ) diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml index d4592dc8aab..45d7c0a530a 100644 --- a/homeassistant/components/seventeentrack/services.yaml +++ b/homeassistant/components/seventeentrack/services.yaml @@ -11,7 +11,7 @@ get_packages: - "ready_to_be_picked_up" - "undelivered" - "delivered" - - "returned" + - "alert" translation_key: package_state config_entry_id: required: true diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index bbd01ed3055..c95a553ae7b 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -37,19 +37,6 @@ } } }, - "issues": { - "deprecate_sensor": { - "title": "17Track package sensors are being deprecated", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::seventeentrack::issues::deprecate_sensor::title%]", - "description": "17Track package sensors are deprecated and will be removed.\nPlease update your automations and scripts to get data using the `seventeentrack.get_packages` action." - } - } - } - } - }, "entity": { "sensor": { "not_found": { @@ -70,8 +57,8 @@ "delivered": { "name": "Delivered" }, - "returned": { - "name": "Returned" + "alert": { + "name": "Alert" }, "package": { "name": "Package {name}" @@ -81,7 +68,7 @@ "services": { "get_packages": { "name": "Get packages", - "description": "Get packages from 17Track", + "description": "Queries the 17track API for the latest package data.", "fields": { "package_state": { "name": "Package states", @@ -95,7 +82,7 @@ }, "archive_package": { "name": "Archive package", - "description": "Archive a package", + "description": "Archives a package using the 17track API.", "fields": { "package_tracking_number": { "name": "Package tracking number", @@ -117,7 +104,7 @@ "ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]", "undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]", "delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]", - "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]" + "alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]" } } } diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 927e3cb0ef2..a56d208d515 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -37,12 +37,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = DomainData( box=box, - dsl=SFRDataUpdateCoordinator(hass, box, "dsl", lambda b: b.dsl_get_info()), - ftth=SFRDataUpdateCoordinator(hass, box, "ftth", lambda b: b.ftth_get_info()), - system=SFRDataUpdateCoordinator( - hass, box, "system", lambda b: b.system_get_info() + dsl=SFRDataUpdateCoordinator( + hass, entry, box, "dsl", lambda b: b.dsl_get_info() + ), + ftth=SFRDataUpdateCoordinator( + hass, entry, box, "ftth", lambda b: b.ftth_get_info() + ), + system=SFRDataUpdateCoordinator( + hass, entry, box, "system", lambda b: b.system_get_info() + ), + wan=SFRDataUpdateCoordinator( + hass, entry, box, "wan", lambda b: b.wan_get_info() ), - wan=SFRDataUpdateCoordinator(hass, box, "wan", lambda b: b.wan_get_info()), ) # Preload system information await data.system.async_config_entry_first_refresh() diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 4ef5e87761d..de40291b0b6 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -62,7 +62,9 @@ WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index bddb1e8f926..9798602ef6b 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -21,7 +21,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .models import DomainData @@ -65,7 +65,9 @@ BUTTON_TYPES: tuple[SFRBoxButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the buttons.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sfr_box/coordinator.py b/homeassistant/components/sfr_box/coordinator.py index 5877d5a454a..e9cb3c592e1 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,9 +19,12 @@ _SCAN_INTERVAL = timedelta(minutes=1) class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]): """Coordinator to manage data updates.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, box: SFRBox, name: str, method: Callable[[SFRBox], Coroutine[Any, Any, _DataT | None]], @@ -28,7 +32,13 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]): """Initialize coordinator.""" self.box = box self._method = method - super().__init__(hass, _LOGGER, name=name, update_interval=_SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name, + update_interval=_SCAN_INTERVAL, + ) async def _async_update_data(self) -> _DataT | None: """Update data.""" diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index ee3285a8f38..8b495da56c3 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -123,7 +123,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( entity_registry_enabled_default=False, options=[ "no_defect", - "of_frame", + "loss_of_frame", "loss_of_signal", "loss_of_power", "loss_of_signal_quality", @@ -217,7 +217,9 @@ def _get_temperature(value: float | None) -> float | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 6f0001e97ce..35e9b1869ff 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -64,11 +64,11 @@ "dsl_line_status": { "name": "DSL line status", "state": { - "no_defect": "No Defect", - "of_frame": "Of Frame", - "loss_of_signal": "Loss Of Signal", - "loss_of_power": "Loss Of Power", - "loss_of_signal_quality": "Loss Of Signal Quality", + "no_defect": "No defect", + "loss_of_frame": "Loss of frame", + "loss_of_signal": "Loss of signal", + "loss_of_power": "Loss of power", + "loss_of_signal_quality": "Loss of signal quality", "unknown": "Unknown" } }, diff --git a/homeassistant/components/sharkiq/coordinator.py b/homeassistant/components/sharkiq/coordinator.py index 381f6ca1a7d..1a4a819cdf6 100644 --- a/homeassistant/components/sharkiq/coordinator.py +++ b/homeassistant/components/sharkiq/coordinator.py @@ -24,6 +24,8 @@ from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Define a wrapper class to update Shark IQ data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -36,10 +38,15 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): self.shark_vacs: dict[str, SharkIqVacuum] = { sharkiq.serial_number: sharkiq for sharkiq in shark_vacs } - self._config_entry = config_entry self._online_dsns: set[str] = set() - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) @property def online_dsns(self) -> set[str]: diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 40b569e13b7..3c4c98db38f 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,16 @@ { "config": { - "flow_title": "Add Shark IQ Account", + "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your Shark Clean account to control your devices.", + "description": "Sign into your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "region": "Region" }, "data_description": { - "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth_confirm": { @@ -37,18 +37,18 @@ "region": { "options": { "europe": "Europe", - "elsewhere": "Everywhere Else" + "elsewhere": "Everywhere else" } } }, "exceptions": { "invalid_room": { - "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." + "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the SharkClean app, including capitalization." } }, "services": { "clean_room": { - "name": "Clean Room", + "name": "Clean room", "description": "Cleans a specific user-defined room or set of rooms.", "fields": { "rooms": { diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 873d3fbd290..daea195a770 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -16,10 +16,9 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK @@ -51,7 +50,7 @@ ATTR_ROOMS = "rooms" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Shark IQ vacuum cleaner.""" coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index e0d9d17d55d..5ca58ec7d01 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -15,6 +15,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -331,3 +332,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b return await hass.config_entries.async_unload_platforms( entry, runtime_data.platforms ) + + +async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> None: + """Remove a config entry.""" + if get_device_entry_gen(entry) in RPC_GENERATIONS and ( + mac_address := entry.unique_id + ): + async_remove_scanner(hass, mac_address) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 556274aa51a..ed2ac68d264 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -15,11 +15,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD -from .coordinator import ShellyConfigEntry +from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RestEntityDescription, @@ -59,6 +60,36 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr """Class to describe a REST binary sensor.""" +class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): + """Represent a RPC binary sensor entity.""" + + entity_description: RpcBinarySensorDescription + + @property + def is_on(self) -> bool: + """Return true if RPC sensor state is on.""" + return bool(self.attribute_value) + + +class RpcBluTrvBinarySensor(RpcBinarySensor): + """Represent a RPC BluTrv binary sensor.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcBinarySensorDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { ("device", "overtemp"): BlockBinarySensorDescription( key="device|overtemp", @@ -232,13 +263,34 @@ RPC_SENSORS: Final = { sub_key="value", has_entity_name=True, ), + "calibration": RpcBinarySensorDescription( + key="blutrv", + sub_key="errors", + name="Calibration", + device_class=BinarySensorDeviceClass.PROBLEM, + value=lambda status, _: False if status is None else "not_calibrated" in status, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvBinarySensor, + ), + "flood": RpcBinarySensorDescription( + key="flood", + sub_key="alarm", + name="Flood", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + "mute": RpcBinarySensorDescription( + key="flood", + sub_key="mute", + name="Mute", + entity_category=EntityCategory.DIAGNOSTIC, + ), } async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -320,17 +372,6 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): return bool(self.attribute_value) -class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity): - """Represent a RPC binary sensor entity.""" - - entity_description: RpcBinarySensorDescription - - @property - def is_on(self) -> bool: - """Return true if RPC sensor state is on.""" - return bool(self.attribute_value) - - class BlockSleepingBinarySensor( ShellySleepingBlockAttributeEntity, BinarySensorEntity, RestoreEntity ): diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index f2b71d19d61..d7eb020d671 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -21,14 +21,22 @@ async def async_connect_scanner( hass: HomeAssistant, coordinator: ShellyRpcCoordinator, scanner_mode: BLEScannerMode, + device_id: str, ) -> CALLBACK_TYPE: """Connect scanner.""" device = coordinator.device - entry = coordinator.entry + entry = coordinator.config_entry source = format_mac(coordinator.mac).upper() scanner = create_scanner(source, entry.title) unload_callbacks = [ - async_register_scanner(hass, scanner), + async_register_scanner( + hass, + scanner, + source_domain=entry.domain, + source_model=coordinator.model, + source_config_entry_id=entry.entry_id, + source_device_id=device_id, + ), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index f1e2f8ef885..1f3c555a64b 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -18,7 +18,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -106,7 +106,7 @@ def async_migrate_unique_ids( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" entry_data = config_entry.runtime_data diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 842abc5ecc4..a3ec9be7cb0 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -22,8 +22,12 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,6 +35,8 @@ from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( + BLU_TRV_TEMPERATURE_SETTINGS, + BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -50,7 +56,7 @@ from .utils import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -69,7 +75,7 @@ async def async_setup_entry( @callback def async_setup_climate_entities( - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: ShellyBlockCoordinator, ) -> None: """Set up online climate devices.""" @@ -96,7 +102,7 @@ def async_setup_climate_entities( def async_restore_climate_entities( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: ShellyBlockCoordinator, ) -> None: """Restore sleeping climate devices.""" @@ -118,12 +124,13 @@ def async_restore_climate_entities( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator climate_key_ids = get_rpc_key_ids(coordinator.device.status, "thermostat") + blutrv_key_ids = get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER) climate_ids = [] for id_ in climate_key_ids: @@ -139,10 +146,11 @@ def async_setup_rpc_entry( unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) - if not climate_ids: - return + if climate_ids: + async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) - async_add_entities(RpcClimate(coordinator, id_) for id_ in climate_ids) + if blutrv_key_ids: + async_add_entities(RpcBluTrvClimate(coordinator, id_) for id_ in blutrv_key_ids) @dataclass @@ -526,3 +534,76 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): await self.call_rpc( "Thermostat.SetConfig", {"config": {"id": self._id, "enable": mode}} ) + + +class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): + """Entity that controls a thermostat on RPC based Shelly devices.""" + + _attr_max_temp = BLU_TRV_TEMPERATURE_SETTINGS["max"] + _attr_min_temp = BLU_TRV_TEMPERATURE_SETTINGS["min"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_target_temperature_step = BLU_TRV_TEMPERATURE_SETTINGS["step"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + + def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + """Initialize.""" + + super().__init__(coordinator, f"{BLU_TRV_IDENTIFIER}:{id_}") + self._id = id_ + self._config = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"] + ble_addr: str = self._config["addr"] + self._attr_unique_id = f"{ble_addr}-{self.key}" + name = self._config["name"] or f"shellyblutrv-{ble_addr.replace(':', '')}" + model_id = self._config.get("local_name") + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)}, + identifiers={(DOMAIN, ble_addr)}, + via_device=(DOMAIN, self.coordinator.mac), + manufacturer="Shelly", + model=BLU_TRV_MODEL_NAME.get(model_id), + model_id=model_id, + name=name, + ) + # Added intentionally to the constructor to avoid double name from base class + self._attr_name = None + + @property + def target_temperature(self) -> float | None: + """Set target temperature.""" + if not self._config["enable"]: + return None + + return cast(float, self.status["target_C"]) + + @property + def current_temperature(self) -> float | None: + """Return current temperature.""" + return cast(float, self.status["current_C"]) + + @property + def hvac_action(self) -> HVACAction: + """HVAC current action.""" + if not self.status["pos"]: + return HVACAction.IDLE + + return HVACAction.HEATING + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (target_temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + await self.call_rpc( + "BluTRV.Call", + { + "id": self._id, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": target_temp}, + }, + timeout=BLU_TRV_TIMEOUT, + ) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 55686464637..5c5e187a0f4 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,7 +7,12 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS +from aioshelly.const import ( + BLOCK_GENERATIONS, + DEFAULT_HTTP_PORT, + MODEL_WALL_DISPLAY, + RPC_GENERATIONS, +) from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, @@ -17,7 +22,6 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -34,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_BLE_SCANNER_MODE, @@ -41,7 +46,6 @@ from .const import ( CONF_SLEEP_PERIOD, DOMAIN, LOGGER, - MODEL_WALL_DISPLAY, BLEScannerMode, ) from .coordinator import async_reconnect_soon @@ -112,7 +116,7 @@ async def validate_input( return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": rpc_device.shelly.get("model"), + "model": rpc_device.xmod_info.get("p") or rpc_device.shelly.get("model"), CONF_GEN: gen, } @@ -164,7 +168,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(self.info[CONF_MAC]) + await self.async_set_unique_id( + self.info[CONF_MAC], raise_on_progress=False + ) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host self.port = port diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 88d8c1f5f17..d47f2b0ae80 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -28,6 +28,7 @@ from aioshelly.const import ( ) from homeassistant.components.number import NumberMode +from homeassistant.components.sensor import SensorDeviceClass DOMAIN: Final = "shelly" @@ -116,6 +117,10 @@ BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = [ # Button/Click events for Block & RPC devices EVENT_SHELLY_CLICK: Final = "shelly.click" +SHELLY_EMIT_EVENT_PATTERN: Final = re.compile( + r"(?:Shelly\s*\.\s*emitEvent\s*\(\s*[\"'`])(\w*)" +) + ATTR_CLICK_TYPE: Final = "click_type" ATTR_CHANNEL: Final = "channel" ATTR_DEVICE: Final = "device" @@ -187,6 +192,13 @@ RPC_THERMOSTAT_SETTINGS: Final = { "step": 0.5, } +BLU_TRV_TEMPERATURE_SETTINGS: Final = { + "min": 4, + "max": 30, + "step": 0.1, + "default": 20.0, +} + # Kelvin value for colorTemp KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 @@ -230,6 +242,7 @@ OTA_SUCCESS = "ota_success" GEN1_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen1/#changelog" GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" +GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_WALL_DISPLAY, MODEL_MOTION, @@ -257,3 +270,11 @@ VIRTUAL_NUMBER_MODE_MAP = { API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") + +# value confirmed by Shelly team +BLU_TRV_TIMEOUT = 60 + +ROLE_TO_DEVICE_CLASS_MAP = { + "current_humidity": SensorDeviceClass.HUMIDITY, + "current_temperature": SensorDeviceClass.TEMPERATURE, +} diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 8273c7626eb..7b4da241043 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -10,7 +10,7 @@ from typing import Any, cast from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType -from aioshelly.const import MODEL_NAMES, MODEL_VALVE +from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -18,8 +18,9 @@ from aioshelly.exceptions import ( RpcCallError, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from propcache import cached_property +from propcache.api import cached_property +from homeassistant.components.bluetooth import async_remove_scanner from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, @@ -30,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .bluetooth import async_connect_scanner @@ -71,6 +72,7 @@ from .utils import ( get_http_port, get_rpc_device_wakeup_period, get_rpc_ws_url, + get_shelly_model_name, update_device_fw_info, ) @@ -94,6 +96,8 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( ): """Coordinator for a Shelly device.""" + config_entry: ShellyConfigEntry + def __init__( self, hass: HomeAssistant, @@ -102,7 +106,6 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( update_interval: float, ) -> None: """Initialize the Shelly device coordinator.""" - self.entry = entry self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None @@ -111,7 +114,13 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( # The device has come online at least once. In the case of a sleeping RPC # device, this means that the device has connected to the WS server at least once. self._came_online_once = False - super().__init__(hass, LOGGER, name=device_name, update_interval=interval_td) + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=device_name, + update_interval=interval_td, + ) self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( hass, @@ -129,12 +138,12 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def model(self) -> str: """Model of the device.""" - return cast(str, self.entry.data["model"]) + return cast(str, self.config_entry.data["model"]) @cached_property def mac(self) -> str: """Mac address of the device.""" - return cast(str, self.entry.unique_id) + return cast(str, self.config_entry.unique_id) @property def sw_version(self) -> str: @@ -144,22 +153,23 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" self._pending_platforms = pending_platforms dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, + config_entry_id=self.config_entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, + identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=MODEL_NAMES.get(self.model), + model=get_shelly_model_name(self.model, self.sleep_period, self.device), model_id=self.model, sw_version=self.sw_version, - hw_version=f"gen{get_device_entry_gen(self.entry)}", - configuration_url=f"http://{get_host(self.entry.data[CONF_HOST])}:{get_http_port(self.entry.data)}", + hw_version=f"gen{get_device_entry_gen(self.config_entry)}", + configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", ) self.device_id = device_entry.id @@ -177,18 +187,18 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( LOGGER.debug("Connecting to Shelly Device - %s", self.name) try: await self.device.initialize() - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) except (DeviceConnectionError, MacAddressMismatchError) as err: LOGGER.debug( "Error connecting to Shelly device %s, error: %r", self.name, err ) return False except InvalidAuthError: - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) return False if not self.device.firmware_supported: - async_create_issue_unsupported_firmware(self.hass, self.entry) + async_create_issue_unsupported_firmware(self.hass, self.config_entry) return False if not self._pending_platforms: @@ -198,7 +208,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( platforms = self._pending_platforms self._pending_platforms = None - data = {**self.entry.data} + data = {**self.config_entry.data} # Update sleep_period old_sleep_period = data[CONF_SLEEP_PERIOD] @@ -209,10 +219,12 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( if new_sleep_period != old_sleep_period: data[CONF_SLEEP_PERIOD] = new_sleep_period - self.hass.config_entries.async_update_entry(self.entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) # Resume platform setup - await self.hass.config_entries.async_forward_entry_setups(self.entry, platforms) + await self.hass.config_entries.async_forward_entry_setups( + self.config_entry, platforms + ) return True @@ -220,7 +232,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( """Reload entry.""" self._debounced_reload.async_cancel() LOGGER.debug("Reloading entry %s", self.name) - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) async def async_shutdown_device_and_start_reauth(self) -> None: """Shutdown Shelly device and start reauth flow.""" @@ -228,7 +240,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( # and won't be able to send commands to the device self.last_update_success = False await self.shutdown() - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): @@ -238,9 +250,8 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self, hass: HomeAssistant, entry: ShellyConfigEntry, device: BlockDevice ) -> None: """Initialize the Shelly block device coordinator.""" - self.entry = entry - if self.sleep_period: - update_interval = UPDATE_PERIOD_MULTIPLIER * self.sleep_period + if sleep_period := entry.data.get(CONF_SLEEP_PERIOD, 0): + update_interval = UPDATE_PERIOD_MULTIPLIER * sleep_period else: update_interval = ( UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] @@ -383,7 +394,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): LOGGER.debug("Shelly %s handle update, type: %s", self.name, update_type) if update_type is BlockUpdateType.ONLINE: self._came_online_once = True - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_device_connect_task(), "block device online", @@ -413,7 +424,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1", translation_key="push_update_failure", translation_placeholders={ - "device_name": self.entry.title, + "device_name": self.config_entry.title, "ip_address": self.device.ip_address, }, ) @@ -460,7 +471,7 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): @@ -470,9 +481,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self, hass: HomeAssistant, entry: ShellyConfigEntry, device: RpcDevice ) -> None: """Initialize the Shelly RPC device coordinator.""" - self.entry = entry - if self.sleep_period: - update_interval = UPDATE_PERIOD_MULTIPLIER * self.sleep_period + if sleep_period := entry.data.get(CONF_SLEEP_PERIOD, 0): + update_interval = UPDATE_PERIOD_MULTIPLIER * sleep_period else: update_interval = RPC_RECONNECT_INTERVAL super().__init__(hass, entry, device, update_interval) @@ -512,9 +522,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ): return False - data = {**self.entry.data} + data = {**self.config_entry.data} data[CONF_SLEEP_PERIOD] = wakeup_period - self.hass.config_entries.async_update_entry(self.entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) update_interval = UPDATE_PERIOD_MULTIPLIER * wakeup_period self.update_interval = timedelta(seconds=update_interval) @@ -691,18 +701,22 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_connect_ble_scanner(self) -> None: """Connect BLE scanner.""" - ble_scanner_mode = self.entry.options.get( + ble_scanner_mode = self.config_entry.options.get( CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) + async_remove_scanner(self.hass, format_mac(self.mac).upper()) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway return + assert self.device_id is not None self._disconnected_callbacks.append( - await async_connect_scanner(self.hass, self, ble_scanner_mode) + await async_connect_scanner( + self.hass, self, ble_scanner_mode, self.device_id + ) ) @callback @@ -713,7 +727,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ): LOGGER.debug("Device %s already connected/connecting", self.name) return - self._connect_task = self.entry.async_create_background_task( + self._connect_task = self.config_entry.async_create_background_task( self.hass, self._async_device_connect_task(), "rpc device online", @@ -730,13 +744,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._came_online_once = True self._async_handle_rpc_device_online() elif update_type is RpcUpdateType.INITIALIZED: - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_connected(), "rpc device init", eager_start=True ) # Make sure entities are marked available self.async_set_updated_data(None) elif update_type is RpcUpdateType.DISCONNECTED: - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_disconnected(True), "rpc device disconnected", @@ -747,7 +761,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) if self.sleep_period: - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) @@ -757,7 +771,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.device.subscribe_updates(self._async_handle_update) if self.device.initialized: # If we are already initialized, we are connected - self.entry.async_create_task( + self.config_entry.async_create_task( self.hass, self._async_connected(), eager_start=True ) @@ -769,7 +783,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): await async_stop_scanner(self.device) await super().shutdown() except InvalidAuthError: - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) return except DeviceConnectionError as err: # If the device is restarting or has gone offline before diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 09e8279bf9b..e9eb5acf161 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ShellyBlockEntity, ShellyRpcEntity @@ -25,7 +25,7 @@ from .utils import get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up covers for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -38,7 +38,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover for device.""" coordinator = config_entry.runtime_data.block @@ -55,7 +55,7 @@ def async_setup_block_entry( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index aea060e09e2..001727c74b3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -196,10 +196,16 @@ def async_setup_rpc_attribute_entities( elif description.use_polling_coordinator: if not sleep_period: entities.append( - sensor_class(polling_coordinator, key, sensor_id, description) + get_entity_class(sensor_class, description)( + polling_coordinator, key, sensor_id, description + ) ) else: - entities.append(sensor_class(coordinator, key, sensor_id, description)) + entities.append( + get_entity_class(sensor_class, description)( + coordinator, key, sensor_id, description + ) + ) if not entities: return @@ -232,7 +238,9 @@ def async_restore_rpc_attribute_entities( if description := sensors.get(attribute): entities.append( - sensor_class(coordinator, key, attribute, description, entry) + get_entity_class(sensor_class, description)( + coordinator, key, attribute, description, entry + ) ) if not entities: @@ -293,6 +301,7 @@ class RpcEntityDescription(EntityDescription): supported: Callable = lambda _: False unit: Callable[[dict], str | None] | None = None options_fn: Callable[[dict], list[str]] | None = None + entity_class: Callable | None = None @dataclass(frozen=True) @@ -381,15 +390,20 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Handle device update.""" self.async_write_ha_state() - async def call_rpc(self, method: str, params: Any) -> Any: + async def call_rpc( + self, method: str, params: Any, timeout: float | None = None + ) -> Any: """Call RPC method.""" LOGGER.debug( - "Call RPC for entity %s, method: %s, params: %s", + "Call RPC for entity %s, method: %s, params: %s, timeout: %s", self.name, method, params, + timeout, ) try: + if timeout: + return await self.coordinator.device.call_rpc(method, params, timeout) return await self.coordinator.device.call_rpc(method, params) except DeviceConnectionError as err: self.coordinator.last_update_success = False @@ -673,3 +687,13 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): "Entity %s comes from a sleeping device, update is not possible", self.entity_id, ) + + +def get_entity_class( + sensor_class: Callable, description: RpcEntityDescription +) -> Callable: + """Return entity class.""" + if description.entity_class is not None: + return description.entity_class + + return sensor_class diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 372d73dea3c..bfd705f447a 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import Block from aioshelly.const import MODEL_I3, RPC_GENERATIONS @@ -17,7 +18,7 @@ from homeassistant.components.event import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,10 +29,12 @@ from .const import ( from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ShellyBlockEntity from .utils import ( + async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, + get_rpc_script_event_types, is_block_momentary_input, is_rpc_momentary_input, ) @@ -68,12 +71,19 @@ RPC_EVENT: Final = ShellyRpcEventDescription( config, status, key ), ) +SCRIPT_EVENT: Final = ShellyRpcEventDescription( + key="script", + translation_key="script", + device_class=None, + entity_registry_enabled_default=False, + has_entity_name=True, +) async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" entities: list[ShellyBlockEvent | ShellyRpcEvent] = [] @@ -95,6 +105,33 @@ async def async_setup_entry( async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) else: entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + + script_instances = get_rpc_key_instances( + coordinator.device.status, SCRIPT_EVENT.key + ) + for script in script_instances: + script_name = get_rpc_entity_name(coordinator.device, script) + if script_name == BLE_SCRIPT_NAME: + continue + + event_types = await get_rpc_script_event_types( + coordinator.device, int(script.split(":")[-1]) + ) + if not event_types: + continue + + entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) + + # If a script is removed, from the device configuration, we need to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + EVENT_DOMAIN, + coordinator.device.status, + "script", + ) + else: coordinator = config_entry.runtime_data.block if TYPE_CHECKING: @@ -170,7 +207,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): ) -> None: """Initialize Shelly entity.""" super().__init__(coordinator) - self.input_index = int(key.split(":")[-1]) + self.event_id = int(key.split(":")[-1]) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -181,6 +218,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + self.async_on_remove( self.coordinator.async_subscribe_input_events(self._async_handle_event) ) @@ -188,6 +226,42 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): @callback def _async_handle_event(self, event: dict[str, Any]) -> None: """Handle the demo button event.""" - if event["id"] == self.input_index: + if event["id"] == self.event_id: self._trigger_event(event["event"]) self.async_write_ha_state() + + +class ShellyRpcScriptEvent(ShellyRpcEvent): + """Represent RPC script event entity.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + event_types: list[str], + ) -> None: + """Initialize Shelly script event entity.""" + super().__init__(coordinator, key, SCRIPT_EVENT) + + self.component = key + self._attr_event_types = event_types + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super(CoordinatorEntity, self).async_added_to_hass() + + self.async_on_remove( + self.coordinator.async_subscribe_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle script event.""" + if event.get("component") == self.component: + event_type = event.get("event") + if event_type not in self.event_types: + # This can happen if we didn't find this event type in the script + return + + self._trigger_event(event_type, event.get("data")) + self.async_write_ha_state() diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 1baf61acf3b..f93abf6b854 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -12,6 +12,9 @@ } }, "number": { + "external_temperature": { + "default": "mdi:thermometer-check" + }, "valve_position": { "default": "mdi:pipe-valve" } @@ -29,6 +32,9 @@ "tilt": { "default": "mdi:angle-acute" }, + "valve_position": { + "default": "mdi:pipe-valve" + }, "valve_status": { "default": "mdi:valve" } diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 5d7bad810b4..ce31533b557 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( brightness_supported, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BLOCK_MAX_TRANSITION_TIME_MS, @@ -53,7 +53,7 @@ from .utils import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -66,7 +66,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for block device.""" coordinator = config_entry.runtime_data.block @@ -96,7 +96,7 @@ def async_setup_block_entry( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index fbf72e6ebe8..e18cd7ca465 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -42,7 +42,7 @@ def async_describe_events( if click_type in RPC_INPUTS_EVENTS_TYPES: rpc_coordinator = get_rpc_coordinator_by_device_id(hass, device_id) if rpc_coordinator and rpc_coordinator.device.initialized: - key = f"input:{channel-1}" + key = f"input:{channel - 1}" input_name = get_rpc_entity_name(rpc_coordinator.device, key) elif click_type in BLOCK_INPUTS_EVENTS_TYPES: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 29c8fd4c369..722fd4c128a 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,11 +8,14 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.2.0"], + "requirements": ["aioshelly==13.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "shelly*" + }, + { + "type": "_shelly._tcp.local." } ] } diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 2aed38fb723..59716f39c7f 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -18,13 +18,14 @@ from homeassistant.components.number import ( NumberMode, RestoreNumber, ) -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -57,6 +58,103 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): min_fn: Callable[[dict], float] | None = None step_fn: Callable[[dict], float] | None = None mode_fn: Callable[[dict], NumberMode] | None = None + method: str + method_params_fn: Callable[[int, float], dict] + + +class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): + """Represent a RPC number entity.""" + + entity_description: RpcNumberDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, key, attribute, description) + + if description.max_fn is not None: + self._attr_native_max_value = description.max_fn( + coordinator.device.config[key] + ) + if description.min_fn is not None: + self._attr_native_min_value = description.min_fn( + coordinator.device.config[key] + ) + if description.step_fn is not None: + self._attr_native_step = description.step_fn(coordinator.device.config[key]) + if description.mode_fn is not None: + self._attr_mode = description.mode_fn(coordinator.device.config[key]) + + @property + def native_value(self) -> float | None: + """Return value of number.""" + if TYPE_CHECKING: + assert isinstance(self.attribute_value, float | None) + + return self.attribute_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + if TYPE_CHECKING: + assert isinstance(self._id, int) + + await self.call_rpc( + self.entity_description.method, + self.entity_description.method_params_fn(self._id, value), + ) + + +class RpcBluTrvNumber(RpcNumber): + """Represent a RPC BluTrv number.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcNumberDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + if TYPE_CHECKING: + assert isinstance(self._id, int) + + await self.call_rpc( + self.entity_description.method, + self.entity_description.method_params_fn(self._id, value), + timeout=BLU_TRV_TIMEOUT, + ) + + +class RpcBluTrvExtTempNumber(RpcBluTrvNumber): + """Represent a RPC BluTrv External Temperature number.""" + + _reported_value: float | None = None + + @property + def native_value(self) -> float | None: + """Return value of number.""" + return self._reported_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + await super().async_set_native_value(value) + + self._reported_value = value + self.async_write_ha_state() NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { @@ -78,6 +176,25 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { RPC_NUMBERS: Final = { + "external_temperature": RpcNumberDescription( + key="blutrv", + sub_key="current_C", + translation_key="external_temperature", + name="External temperature", + native_min_value=-50, + native_max_value=50, + native_step=0.1, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + method="BluTRV.Call", + method_params_fn=lambda idx, value: { + "id": idx, + "method": "Trv.SetExternalTemperature", + "params": {"id": 0, "t_C": value}, + }, + entity_class=RpcBluTrvExtTempNumber, + ), "number": RpcNumberDescription( key="number", sub_key="value", @@ -87,11 +204,33 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, + method="Number.Set", + method_params_fn=lambda idx, value: {"id": idx, "value": value}, + ), + "valve_position": RpcNumberDescription( + key="blutrv", + sub_key="pos", + translation_key="valve_position", + name="Valve position", + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=PERCENTAGE, + method="BluTRV.Call", + method_params_fn=lambda idx, value: { + "id": idx, + "method": "Trv.SetPosition", + "params": {"id": 0, "pos": int(value)}, + }, + removal_condition=lambda config, _status, key: config[key].get("enable", True) + is True, + entity_class=RpcBluTrvNumber, ), } @@ -99,7 +238,7 @@ RPC_NUMBERS: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up numbers for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -190,44 +329,3 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() - - -class RpcNumber(ShellyRpcAttributeEntity, NumberEntity): - """Represent a RPC number entity.""" - - entity_description: RpcNumberDescription - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcNumberDescription, - ) -> None: - """Initialize sensor.""" - super().__init__(coordinator, key, attribute, description) - - if description.max_fn is not None: - self._attr_native_max_value = description.max_fn( - coordinator.device.config[key] - ) - if description.min_fn is not None: - self._attr_native_min_value = description.min_fn( - coordinator.device.config[key] - ) - if description.step_fn is not None: - self._attr_native_step = description.step_fn(coordinator.device.config[key]) - if description.mode_fn is not None: - self._attr_mode = description.mode_fn(coordinator.device.config[key]) - - @property - def native_value(self) -> float | None: - """Return value of number.""" - if TYPE_CHECKING: - assert isinstance(self.attribute_value, float | None) - - return self.attribute_value - - async def async_set_native_value(self, value: float) -> None: - """Change the value.""" - await self.call_rpc("Number.Set", {"id": self._id, "value": value}) diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 0caf4661240..1fb3dfb3447 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -13,7 +13,7 @@ from homeassistant.components.select import ( SelectEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( @@ -45,7 +45,7 @@ RPC_SELECT_ENTITIES: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up selectors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 03ce080db8e..183a1aa06a1 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Final, cast @@ -33,11 +34,12 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS +from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP, SHAIR_MAX_WORK_HOURS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -70,12 +72,71 @@ class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" + device_class_fn: Callable[[dict], SensorDeviceClass | None] | None = None + @dataclass(frozen=True, kw_only=True) class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" +class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): + """Represent a RPC sensor.""" + + entity_description: RpcSensorDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize select.""" + super().__init__(coordinator, key, attribute, description) + + if self.option_map: + self._attr_options = list(self.option_map.values()) + + if description.device_class_fn is not None: + if device_class := description.device_class_fn( + coordinator.device.config[key] + ): + self._attr_device_class = device_class + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + attribute_value = self.attribute_value + + if not self.option_map: + return attribute_value + + if not isinstance(attribute_value, str): + return None + + return self.option_map[attribute_value] + + +class RpcBluTrvSensor(RpcSensor): + """Represent a RPC BluTrv sensor.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSensorDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator, key, attribute, description) + ble_addr: str = coordinator.device.config[key]["addr"] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + + SENSORS: dict[tuple[str, str], BlockSensorDescription] = { ("device", "battery"): BlockSensorDescription( key="device|battery", @@ -770,6 +831,18 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "ret_energy_pm1": RpcSensorDescription( + key="pm1", + sub_key="ret_aenergy", + name="Total active returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "energy_cct": RpcSensorDescription( key="cct", sub_key="aenergy", @@ -1202,6 +1275,9 @@ RPC_SENSORS: Final = { unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] else None, + device_class_fn=lambda config: ROLE_TO_DEVICE_CLASS_MAP.get(config["role"]) + if "role" in config + else None, ), "enum": RpcSensorDescription( key="enum", @@ -1210,13 +1286,45 @@ RPC_SENSORS: Final = { options_fn=lambda config: config["options"], device_class=SensorDeviceClass.ENUM, ), + "valve_position": RpcSensorDescription( + key="blutrv", + sub_key="pos", + name="Valve position", + translation_key="valve_position", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + removal_condition=lambda config, _status, key: config[key].get("enable", False) + is False, + entity_class=RpcBluTrvSensor, + ), + "blutrv_battery": RpcSensorDescription( + key="blutrv", + sub_key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvSensor, + ), + "blutrv_rssi": RpcSensorDescription( + key="blutrv", + sub_key="rssi", + name="Signal strength", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_class=RpcBluTrvSensor, + ), } async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -1315,38 +1423,6 @@ class RestSensor(ShellyRestAttributeEntity, SensorEntity): return self.attribute_value -class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): - """Represent a RPC sensor.""" - - entity_description: RpcSensorDescription - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcSensorDescription, - ) -> None: - """Initialize select.""" - super().__init__(coordinator, key, attribute, description) - - if self.option_map: - self._attr_options = list(self.option_map.values()) - - @property - def native_value(self) -> StateType: - """Return value of sensor.""" - attribute_value = self.attribute_value - - if not self.option_map: - return attribute_value - - if not isinstance(attribute_value, str): - return None - - return self.option_map[attribute_value] - - class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor): """Represent a block sleeping sensor.""" diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 134704cb0ff..68708a2cc2b 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -2,12 +2,14 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS +from aioshelly.const import MODEL_2, MODEL_25, RPC_GENERATIONS +from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( DOMAIN as SWITCH_PLATFORM, SwitchEntity, @@ -15,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity @@ -26,21 +28,17 @@ from .entity import ( RpcEntityDescription, ShellyBlockEntity, ShellyRpcAttributeEntity, - ShellyRpcEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, - async_setup_rpc_attribute_entities, + async_setup_entry_rpc, ) from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_key_ids, get_virtual_component_ids, is_block_channel_type_light, - is_rpc_channel_type_light, - is_rpc_thermostat_internal_actuator, - is_rpc_thermostat_mode, + is_rpc_exclude_from_relay, ) @@ -60,24 +58,50 @@ MOTION_SWITCH = BlockSwitchDescription( class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): """Class to describe a RPC virtual switch.""" + is_on: Callable[[dict[str, Any]], bool] + method_on: str + method_off: str + method_params_fn: Callable[[int | None, bool], dict] -RPC_VIRTUAL_SWITCH = RpcSwitchDescription( - key="boolean", - sub_key="value", -) -RPC_SCRIPT_SWITCH = RpcSwitchDescription( - key="script", - sub_key="running", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, -) +RPC_RELAY_SWITCHES = { + "switch": RpcSwitchDescription( + key="switch", + sub_key="output", + removal_condition=is_rpc_exclude_from_relay, + is_on=lambda status: bool(status["output"]), + method_on="Switch.Set", + method_off="Switch.Set", + method_params_fn=lambda id, value: {"id": id, "on": value}, + ), +} + +RPC_SWITCHES = { + "boolean": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="Boolean.Set", + method_off="Boolean.Set", + method_params_fn=lambda id, value: {"id": id, "value": value}, + ), + "script": RpcSwitchDescription( + key="script", + sub_key="running", + is_on=lambda status: bool(status["running"]), + method_on="Script.Start", + method_off="Script.Stop", + method_params_fn=lambda id, _: {"id": id}, + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +} async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -90,7 +114,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for block device.""" coordinator = config_entry.runtime_data.block @@ -120,9 +144,8 @@ def async_setup_block_entry( relay_blocks = [] assert coordinator.device.blocks for block in coordinator.device.blocks: - if ( - block.type != "relay" - or block.channel is not None + if block.type != "relay" or ( + block.channel is not None and is_block_channel_type_light( coordinator.device.settings, int(block.channel) ) @@ -143,52 +166,18 @@ def async_setup_block_entry( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator - switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") - switch_ids = [] - for id_ in switch_key_ids: - if is_rpc_channel_type_light(coordinator.device.config, id_): - continue - - if coordinator.model == MODEL_WALL_DISPLAY: - # There are three configuration scenarios for WallDisplay: - # - relay mode (no thermostat) - # - thermostat mode using the internal relay as an actuator - # - thermostat mode using an external (from another device) relay as - # an actuator - if not is_rpc_thermostat_mode(id_, coordinator.device.status): - # The device is not in thermostat mode, we need to remove a climate - # entity - unique_id = f"{coordinator.mac}-thermostat:{id_}" - async_remove_shelly_entity(hass, "climate", unique_id) - elif is_rpc_thermostat_internal_actuator(coordinator.device.status): - # The internal relay is an actuator, skip this ID so as not to create - # a switch entity - continue - - switch_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "light", unique_id) - - async_setup_rpc_attribute_entities( - hass, - config_entry, - async_add_entities, - {"boolean": RPC_VIRTUAL_SWITCH}, - RpcVirtualSwitch, + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_RELAY_SWITCHES, RpcRelaySwitch ) - async_setup_rpc_attribute_entities( - hass, - config_entry, - async_add_entities, - {"script": RPC_SCRIPT_SWITCH}, - RpcScriptSwitch, + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch ) # the user can remove virtual components from the device configuration, so we need @@ -216,10 +205,16 @@ def async_setup_rpc_entry( "script", ) - if not switch_ids: - return - - async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) + # if the climate is removed, from the device configuration, we need + # to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + CLIMATE_PLATFORM, + coordinator.device.status, + "thermostat", + ) class BlockSleepingMotionSwitch( @@ -303,30 +298,8 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): super()._update_callback() -class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): - """Entity that controls a relay on RPC based Shelly devices.""" - - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: - """Initialize relay switch.""" - super().__init__(coordinator, f"switch:{id_}") - self._id = id_ - - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["output"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) - - -class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity): - """Entity that controls a virtual boolean component on RPC based Shelly devices.""" +class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): + """Entity that controls a switch on RPC based Shelly devices.""" entity_description: RpcSwitchDescription _attr_has_entity_name = True @@ -334,32 +307,36 @@ class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity): @property def is_on(self) -> bool: """If switch is on.""" - return bool(self.attribute_value) + return self.entity_description.is_on(self.status) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on relay.""" - await self.call_rpc("Boolean.Set", {"id": self._id, "value": True}) + await self.call_rpc( + self.entity_description.method_on, + self.entity_description.method_params_fn(self._id, True), + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off relay.""" - await self.call_rpc("Boolean.Set", {"id": self._id, "value": False}) + await self.call_rpc( + self.entity_description.method_off, + self.entity_description.method_params_fn(self._id, False), + ) -class RpcScriptSwitch(ShellyRpcAttributeEntity, SwitchEntity): - """Entity that controls a script component on RPC based Shelly devices.""" +class RpcRelaySwitch(RpcSwitch): + """Entity that controls a switch on RPC based Shelly devices.""" - entity_description: RpcSwitchDescription - _attr_has_entity_name = True + # False to avoid double naming as True is inerithed from base class + _attr_has_entity_name = False - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["running"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Script.Start", {"id": self._id}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Script.Stop", {"id": self._id}) + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, key, attribute, description) + self._attr_unique_id: str = f"{coordinator.mac}-{key}" diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index 66e2ee4c715..f64d1252b7e 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -13,7 +13,7 @@ from homeassistant.components.text import ( TextEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyConfigEntry from .entity import ( @@ -45,7 +45,7 @@ RPC_TEXT_ENTITIES: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index f22547acf50..b1aa84b2640 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -22,7 +22,7 @@ from homeassistant.components.update import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS @@ -104,7 +104,7 @@ RPC_UPDATES: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Shelly component.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index df374624e3d..d9e86427d0b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -50,11 +50,13 @@ from .const import ( DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID, GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, + SHELLY_EMIT_EVENT_PATTERN, SHIX3_1_INPUTS_EVENTS_TYPES, UPTIME_DEVIATION, VIRTUAL_COMPONENTS_MAP, @@ -137,7 +139,7 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str: else: base = ord("1") - return f"{entity_name} channel {chr(int(block.channel)+base)}" + return f"{entity_name} channel {chr(int(block.channel) + base)}" def is_block_momentary_input( @@ -200,7 +202,7 @@ def get_block_input_triggers( subtype = "button" else: assert block.channel - subtype = f"button{int(block.channel)+1}" + subtype = f"button{int(block.channel) + 1}" if device.settings["device"]["type"] in SHBTN_MODELS: trigger_types = SHBTN_INPUTS_EVENTS_TYPES @@ -313,6 +315,27 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) +def get_shelly_model_name( + model: str, + sleep_period: int, + device: BlockDevice | RpcDevice, +) -> str | None: + """Get Shelly model name. + + Assume that XMOD devices are not sleepy devices. + """ + if ( + sleep_period == 0 + and isinstance(device, RpcDevice) + and (model_name := device.xmod_info.get("n")) + ): + # Use the model name from XMOD data + return cast(str, model_name) + + # Use the model name from aioshelly + return cast(str, MODEL_NAMES.get(model)) + + def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") @@ -409,7 +432,7 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: continue for trigger_type in RPC_INPUTS_EVENTS_TYPES: - subtype = f"button{id_+1}" + subtype = f"button{id_ + 1}" triggers.append((trigger_type, subtype)) return triggers @@ -453,9 +476,14 @@ def mac_address_from_name(name: str) -> str | None: def get_release_url(gen: int, model: str, beta: bool) -> str | None: """Return release URL or None.""" - if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: + if ( + beta and gen in BLOCK_GENERATIONS + ) or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: return None + if beta: + return GEN2_BETA_RELEASE_URL + return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL @@ -592,3 +620,21 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None: url = URL(raw_url) ws_url = url.with_scheme("wss" if url.scheme == "https" else "ws") return str(ws_url.joinpath(API_WS_URL.removeprefix("/"))) + + +async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: + """Return a list of event types for a specific script.""" + code_response = await device.script_getcode(id) + matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) + return sorted([*{str(event_type.group(1)) for event_type in matches}]) + + +def is_rpc_exclude_from_relay( + settings: dict[str, Any], status: dict[str, Any], channel: str +) -> bool: + """Return true if rpc channel should be excludeed from switch platform.""" + ch = int(channel.split(":")[1]) + if is_rpc_thermostat_internal_actuator(status): + return True + + return is_rpc_channel_type_light(settings, ch) diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index ea6feaabe69..1829f663b22 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -15,7 +15,7 @@ from homeassistant.components.valve import ( ValveEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry from .entity import ( @@ -42,7 +42,7 @@ GAS_VALVE = BlockValveDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up valves for device.""" if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS: @@ -53,7 +53,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up valve for device.""" coordinator = config_entry.runtime_data.block diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index 867b58ad1ba..ef0f4dafd83 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 531bbf37980..4ce596e72f0 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonValueType, load_json_array diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 1a6370f4168..118287f70d2 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -3,8 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 82b6cbfc7f5..2952c283082 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -11,7 +11,7 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NoMatchingShoppingListItem, ShoppingData from .const import DOMAIN @@ -20,7 +20,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the shopping_list todo platform.""" shopping_data = hass.data[DOMAIN] diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 7ea878f538d..bb6a0669a99 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -16,7 +16,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, KEY_ALARM, PREVIOUS_STATE from .entity import SIABaseEntity, SIAEntityDescription @@ -69,7 +69,7 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_entities( diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index 4c8e4ca6130..e1b40dc2e55 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_ACCOUNT, @@ -105,7 +105,7 @@ def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]: async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SIA binary sensors from a config entry.""" async_add_entities(generate_binary_sensors(entry)) diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 8f9190e4436..aece5675cbc 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index acc8309af26..222b61456c4 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -22,10 +22,10 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 53a255da5ff..bc007eaa689 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/simplefin/__init__.py b/homeassistant/components/simplefin/__init__.py index c47b3118415..1fe2f2a6189 100644 --- a/homeassistant/components/simplefin/__init__.py +++ b/homeassistant/components/simplefin/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from simplefin4py import SimpleFin -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_ACCESS_URL -from .coordinator import SimpleFinDataUpdateCoordinator +from .coordinator import SimpleFinConfigEntry, SimpleFinDataUpdateCoordinator PLATFORMS: list[str] = [ Platform.BINARY_SENSOR, @@ -17,20 +16,17 @@ PLATFORMS: list[str] = [ ] -type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool: """Set up from a config entry.""" access_url = entry.data[CONF_ACCESS_URL] sf_client = SimpleFin(access_url) - sf_coordinator = SimpleFinDataUpdateCoordinator(hass, sf_client) + sf_coordinator = SimpleFinDataUpdateCoordinator(hass, entry, sf_client) await sf_coordinator.async_config_entry_first_refresh() entry.runtime_data = sf_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/simplefin/binary_sensor.py b/homeassistant/components/simplefin/binary_sensor.py index 5805fc370b6..af97fe9a394 100644 --- a/homeassistant/components/simplefin/binary_sensor.py +++ b/homeassistant/components/simplefin/binary_sensor.py @@ -12,9 +12,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpleFinConfigEntry +from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity @@ -39,7 +39,7 @@ SIMPLEFIN_BINARY_SENSORS: tuple[SimpleFinBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SimpleFinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpleFIN sensors for config entries.""" diff --git a/homeassistant/components/simplefin/coordinator.py b/homeassistant/components/simplefin/coordinator.py index 7fa5aedb7a1..08e9732c6b7 100644 --- a/homeassistant/components/simplefin/coordinator.py +++ b/homeassistant/components/simplefin/coordinator.py @@ -15,17 +15,22 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER +type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] + class SimpleFinDataUpdateCoordinator(DataUpdateCoordinator[FinancialData]): """Data update coordinator for the SimpleFIN integration.""" - config_entry: ConfigEntry + config_entry: SimpleFinConfigEntry - def __init__(self, hass: HomeAssistant, client: SimpleFin) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SimpleFinConfigEntry, client: SimpleFin + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name="simplefin", update_interval=timedelta(hours=4), ) diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py index b2167a2c014..183a198040b 100644 --- a/homeassistant/components/simplefin/sensor.py +++ b/homeassistant/components/simplefin/sensor.py @@ -16,10 +16,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import SimpleFinConfigEntry +from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity @@ -55,7 +55,7 @@ SIMPLEFIN_SENSORS: tuple[SimpleFinSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SimpleFinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpleFIN sensors for config entries.""" diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 2f19c5117a4..8a75baa69c6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -39,7 +39,7 @@ from simplipy.websocket import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_ID, @@ -402,12 +402,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of SimpliSafe, deregister any services # defined during integration setup: for service_name in SERVICES: diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 18f2d8ddcd5..c5a1b2bc708 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -31,7 +31,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import ( @@ -103,7 +103,9 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a SimpliSafe alarm control panel based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 0310e958e6e..38a80ddd354 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN, LOGGER @@ -34,6 +34,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.PANIC_BUTTON, DeviceTypes.REMOTE, DeviceTypes.SIREN, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, @@ -47,6 +48,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the # SMOKE device class for simplicity: @@ -55,7 +57,9 @@ TRIGGERED_SENSOR_TYPES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe binary sensors based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index f0272d09f61..129209354c3 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN @@ -46,7 +46,9 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe buttons based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index c610223bff1..9e29bb2051b 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -13,7 +13,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN, LOGGER @@ -31,7 +31,9 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe locks based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index a5f46e87a7c..b82162f0fe7 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN, LOGGER @@ -22,7 +22,9 @@ from .entity import SimpliSafeEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe freeze sensors based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py index 16780a05704..8c906d26c23 100644 --- a/homeassistant/components/sinch/notify.py +++ b/homeassistant/components/sinch/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DOMAIN = "sinch" diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 9ce6898fd93..65d7848c618 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any, TypedDict, cast, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -68,10 +68,8 @@ def process_turn_on_params( isinstance(siren.available_tones, dict) and tone in siren.available_tones.values() ) - if ( - not siren.available_tones - or tone not in siren.available_tones - and not is_tone_dict_value + if not siren.available_tones or ( + tone not in siren.available_tones and not is_tone_dict_value ): raise ValueError( f"Invalid tone specified for entity {siren.entity_id}: {tone}, " diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index da8d670d412..1406826e471 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index b0ad48ed985..7507175b321 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -14,8 +14,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index a55dfb2a52b..13cddf99332 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py index 05a464f73a6..1ecd6c3716e 100644 --- a/homeassistant/components/sky_remote/remote.py +++ b/homeassistant/components/sky_remote/remote.py @@ -10,7 +10,7 @@ from homeassistant.components.remote import RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SkyRemoteConfigEntry from .const import DOMAIN @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config: SkyRemoteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sky remote platform.""" async_add_entities( diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 6cb5064b40e..650e62bc4a1 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 0282ad40254..5baa4ad83ad 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Unable to connect to Skybell service: {ex}") from ex device_coordinators: list[SkybellDataUpdateCoordinator] = [ - SkybellDataUpdateCoordinator(hass, device) for device in devices + SkybellDataUpdateCoordinator(hass, entry, device) for device in devices ] await asyncio.gather( *[ diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 3c2d90b2630..cc42da48b26 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -31,7 +31,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell binary sensor.""" async_add_entities( diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 683b840debe..4ee873f8350 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -30,7 +30,9 @@ CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell camera.""" entities = [] diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py index 55e34df5c63..48e67c63ac9 100644 --- a/homeassistant/components/skybell/coordinator.py +++ b/homeassistant/components/skybell/coordinator.py @@ -16,11 +16,14 @@ class SkybellDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device: SkybellDevice) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: SkybellDevice + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=device.name, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index cba9e70c848..3f924f68da8 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -15,14 +15,16 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import SkybellEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell switch.""" async_add_entities( diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 5f0df77ecfa..a67fdae3b35 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .entity import DOMAIN, SkybellEntity @@ -88,7 +88,9 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell sensor.""" async_add_entities( diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index fa4f723573f..858363043ca 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -7,7 +7,7 @@ from typing import Any, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import SkybellEntity @@ -29,7 +29,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SkyBell switch.""" async_add_entities( diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 6fce38e4774..aa67739016d 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform @@ -40,7 +40,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Slack from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) - slack = WebClient(token=entry.data[CONF_API_KEY], run_async=True, session=session) + slack = AsyncWebClient( + token=entry.data[CONF_API_KEY], session=session + ) # No run_async try: res = await slack.auth_test() @@ -49,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Invalid API key") return False raise ConfigEntryNotReady("Error while setting up integration") from ex + data = { DATA_CLIENT: slack, ATTR_URL: res[ATTR_URL], diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 7f6d7288606..fcdc2e8b362 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,8 +4,8 @@ from __future__ import annotations import logging -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,10 +57,10 @@ class SlackFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_try_connect( self, token: str - ) -> tuple[str, None] | tuple[None, dict[str, str]]: + ) -> tuple[str, None] | tuple[None, AsyncSlackResponse]: """Try connecting to Slack.""" session = aiohttp_client.async_get_clientsession(self.hass) - client = WebClient(token=token, run_async=True, session=session) + client = AsyncWebClient(token=token, session=session) # No run_async try: info = await client.auth_test() diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py index 7147186ee9b..30218360054 100644 --- a/homeassistant/components/slack/entity.py +++ b/homeassistant/components/slack/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -14,21 +14,18 @@ from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN class SlackEntity(Entity): """Representation of a Slack entity.""" - _attr_attribution = "Data provided by Slack" - _attr_has_entity_name = True - def __init__( self, - data: dict[str, str | WebClient], + data: dict[str, AsyncWebClient], description: EntityDescription, entry: ConfigEntry, ) -> None: """Initialize a Slack entity.""" - self._client = data[DATA_CLIENT] + self._client: AsyncWebClient = data[DATA_CLIENT] self.entity_description = description self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=data[ATTR_URL], + configuration_url=str(data[ATTR_URL]), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, manufacturer=DEFAULT_NAME, diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 1b35db6f061..3b2322283fe 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["slack"], - "requirements": ["slackclient==2.5.0"] + "requirements": ["slack_sdk==3.33.4"] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 28f9dd203ff..16dd212301a 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -5,13 +5,13 @@ from __future__ import annotations import asyncio import logging import os -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from urllib.parse import urlparse -from aiohttp import BasicAuth, FormData +from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack import WebClient from slack.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol from homeassistant.components.notify import ( @@ -38,6 +38,7 @@ from .const import ( DATA_CLIENT, SLACK_DATA, ) +from .utils import upload_file_to_slack _LOGGER = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class SlackNotificationService(BaseNotificationService): def __init__( self, hass: HomeAssistant, - client: WebClient, + client: AsyncWebClient, config: dict[str, str], ) -> None: """Initialize.""" @@ -160,17 +161,23 @@ class SlackNotificationService(BaseNotificationService): parsed_url = urlparse(path) filename = os.path.basename(parsed_url.path) - try: - await self._client.files_upload( - channels=",".join(targets), - file=path, - filename=filename, - initial_comment=message, - title=title or filename, - thread_ts=thread_ts or "", - ) - except (SlackApiError, ClientError) as err: - _LOGGER.error("Error while uploading file-based message: %r", err) + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values + + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return + + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=None, + file_path=path, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_remote_file_message( self, @@ -183,12 +190,7 @@ class SlackNotificationService(BaseNotificationService): username: str | None = None, password: str | None = None, ) -> None: - """Upload a remote file (with message) to Slack. - - Note that we bypass the python-slackclient WebClient and use aiohttp directly, - as the former would require us to download the entire remote file into memory - first before uploading it to Slack. - """ + """Upload a remote file (with message) to Slack.""" if not self._hass.config.is_allowed_external_url(url): _LOGGER.error("URL is not allowed: %s", url) return @@ -196,36 +198,35 @@ class SlackNotificationService(BaseNotificationService): filename = _async_get_filename_from_url(url) session = aiohttp_client.async_get_clientsession(self._hass) + # Fetch the remote file kwargs: AuthDictT = {} - if username and password is not None: + if username and password: kwargs = {"auth": BasicAuth(username, password=password)} - resp = await session.request("get", url, **kwargs) - try: - resp.raise_for_status() + async with session.get(url, **kwargs) as resp: + resp.raise_for_status() + file_content = await resp.read() except ClientError as err: _LOGGER.error("Error while retrieving %s: %r", url, err) return - form_data: FormDataT = { - "channels": ",".join(targets), - "filename": filename, - "initial_comment": message, - "title": title or filename, - "token": self._client.token, - } + channel_ids = [await self._async_get_channel_id(target) for target in targets] + channel_ids = [cid for cid in channel_ids if cid] # Remove None values - if thread_ts: - form_data["thread_ts"] = thread_ts + if not channel_ids: + _LOGGER.error("No valid channel IDs resolved for targets: %s", targets) + return - data = FormData(form_data, charset="utf-8") - data.add_field("file", resp.content, filename=filename) - - try: - await session.post("https://slack.com/api/files.upload", data=data) - except ClientError as err: - _LOGGER.error("Error while uploading file message: %r", err) + await upload_file_to_slack( + client=self._client, + channel_ids=channel_ids, + file_content=file_content, + filename=filename, + title=title, + message=message, + thread_ts=thread_ts, + ) async def _async_send_text_only_message( self, @@ -327,3 +328,46 @@ class SlackNotificationService(BaseNotificationService): title, thread_ts=data.get(ATTR_THREAD_TS), ) + + async def _async_get_channel_id(self, channel_name: str) -> str | None: + """Get the Slack channel ID from the channel name. + + This method retrieves the channel ID for a given Slack channel name by + querying the Slack API. It handles both public and private channels. + Including this so users can provide channel names instead of IDs. + + Args: + channel_name (str): The name of the Slack channel. + + Returns: + str | None: The ID of the Slack channel if found, otherwise None. + + Raises: + SlackApiError: If there is an error while communicating with the Slack API. + + """ + try: + # Remove # if present + channel_name = channel_name.lstrip("#") + + # Get channel list + # Multiple types is not working. Tested here: https://api.slack.com/methods/conversations.list/test + # response = await self._client.conversations_list(types="public_channel,private_channel") + # + # Workaround for the types parameter not working + channels = [] + for channel_type in ("public_channel", "private_channel"): + response = await self._client.conversations_list(types=channel_type) + channels.extend(response["channels"]) + + # Find channel ID + for channel in channels: + if channel["name"] == channel_name: + return cast(str, channel["id"]) + + _LOGGER.error("Channel %s not found", channel_name) + + except SlackApiError as err: + _LOGGER.error("Error getting channel ID: %r", err) + + return None diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index 9e3beaadd8b..042ab00916e 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from slack import WebClient +from slack_sdk.web.async_client import AsyncWebClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,8 +11,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA from .entity import SlackEntity @@ -21,7 +21,7 @@ from .entity import SlackEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Slack select.""" async_add_entities( @@ -43,7 +43,7 @@ async def async_setup_entry( class SlackSensorEntity(SlackEntity, SensorEntity): """Representation of a Slack sensor.""" - _client: WebClient + _client: AsyncWebClient async def async_update(self) -> None: """Get the latest status.""" diff --git a/homeassistant/components/slack/utils.py b/homeassistant/components/slack/utils.py new file mode 100644 index 00000000000..7619d7d265f --- /dev/null +++ b/homeassistant/components/slack/utils.py @@ -0,0 +1,62 @@ +"""Utils for the Slack integration.""" + +import logging + +import aiofiles +from slack_sdk.errors import SlackApiError +from slack_sdk.web.async_client import AsyncWebClient + +_LOGGER = logging.getLogger(__name__) + + +async def upload_file_to_slack( + client: AsyncWebClient, + channel_ids: list[str | None], + file_content: bytes | str | None, + filename: str, + title: str | None, + message: str, + thread_ts: str | None, + file_path: str | None = None, # Allow passing a file path +) -> None: + """Upload a file to Slack for the specified channel IDs. + + Args: + client (AsyncWebClient): The Slack WebClient instance. + channel_ids (list[str | None]): List of channel IDs to upload the file to. + file_content (Union[bytes, str, None]): Content of the file (local or remote). If None, file_path is used. + filename (str): The file's name. + title (str | None): Title of the file in Slack. + message (str): Initial comment to accompany the file. + thread_ts (str | None): Thread timestamp for threading messages. + file_path (str | None): Path to the local file to be read if file_content is None. + + Raises: + SlackApiError: If the Slack API call fails. + OSError: If there is an error reading the file. + + """ + if file_content is None and file_path: + # Read file asynchronously if file_content is not provided + try: + async with aiofiles.open(file_path, "rb") as file: + file_content = await file.read() + except OSError as os_err: + _LOGGER.error("Error reading file %s: %r", file_path, os_err) + return + + for channel_id in channel_ids: + try: + await client.files_upload_v2( + channel=channel_id, + file=file_content, + filename=filename, + title=title or filename, + initial_comment=message, + thread_ts=thread_ts or "", + ) + _LOGGER.info("Successfully uploaded file to channel %s", channel_id) + except SlackApiError as err: + _LOGGER.error( + "Error while uploading file to channel %s: %r", channel_id, err + ) diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 6506be06e72..565611fe169 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -17,9 +17,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER @@ -95,8 +94,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _async_migrate_unique_ids(hass, entry, gateway) - coordinator = SleepIQDataUpdateCoordinator(hass, gateway, email) - pause_coordinator = SleepIQPauseUpdateCoordinator(hass, gateway, email) + coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway) + pause_coordinator = SleepIQPauseUpdateCoordinator(hass, entry, gateway) # Call the SleepIQ API to refresh data await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/sleepiq/binary_sensor.py b/homeassistant/components/sleepiq/binary_sensor.py index cb56a516b9b..99fff9c49b0 100644 --- a/homeassistant/components/sleepiq/binary_sensor.py +++ b/homeassistant/components/sleepiq/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import SleepIQSleeperEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed binary sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index 94b010066c9..74b1bc0789f 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -11,7 +11,7 @@ from asyncsleepiq import SleepIQBed from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SleepIQData @@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number buttons.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 7fe4f964b36..46b754976e5 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -7,6 +7,8 @@ import logging from asyncsleepiq import AsyncSleepIQ +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,17 +21,20 @@ LONGER_UPDATE_INTERVAL = timedelta(minutes=5) class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: AsyncSleepIQ, - username: str, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - name=f"{username}@SleepIQ", + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQ", update_interval=UPDATE_INTERVAL, ) self.client = client @@ -45,17 +50,20 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: AsyncSleepIQ, - username: str, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - name=f"{username}@SleepIQPause", + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQPause", update_interval=LONGER_UPDATE_INTERVAL, ) self.client = client diff --git a/homeassistant/components/sleepiq/light.py b/homeassistant/components/sleepiq/light.py index 781bd8e600a..542c212df27 100644 --- a/homeassistant/components/sleepiq/light.py +++ b/homeassistant/components/sleepiq/light.py @@ -8,7 +8,7 @@ from asyncsleepiq import SleepIQBed, SleepIQLight from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed lights.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 905ceab18bd..53d6c366e46 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -17,7 +17,7 @@ from asyncsleepiq import ( from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, @@ -138,7 +138,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 0a09aa4d657..7d059ba6b59 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -13,7 +13,7 @@ from asyncsleepiq import ( from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -23,7 +23,7 @@ from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ foundation preset select entities.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 413e8e4d856..ca4fbc186ed 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -7,7 +7,7 @@ from asyncsleepiq import SleepIQBed, SleepIQSleeper from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, PRESSURE, SLEEP_NUMBER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -19,7 +19,7 @@ SENSORS = [PRESSURE, SLEEP_NUMBER] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/switch.py b/homeassistant/components/sleepiq/switch.py index 9fc8ca9d20e..8363782c064 100644 --- a/homeassistant/components/sleepiq/switch.py +++ b/homeassistant/components/sleepiq/switch.py @@ -9,7 +9,7 @@ from asyncsleepiq import SleepIQBed from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SleepIQData, SleepIQPauseUpdateCoordinator @@ -19,7 +19,7 @@ from .entity import SleepIQBedEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number switches.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 5b4867bf337..4690fe8016c 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SWITCH] -type SlideConfigEntry = ConfigEntry[SlideCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index 795cd4f1c2e..3d5de33303d 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -13,11 +13,10 @@ from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SlideConfigEntry from .const import DOMAIN -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity PARALLEL_UPDATES = 1 @@ -26,7 +25,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: SlideConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button for Slide platform.""" @@ -44,7 +43,7 @@ class SlideButton(SlideEntity, ButtonEntity): def __init__(self, coordinator: SlideCoordinator) -> None: """Initialize the slide button.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data["mac"]}-calibrate" + self._attr_unique_id = f"{coordinator.data['mac']}-calibrate" async def async_press(self) -> None: """Send out a calibrate command.""" diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index a4255f0769f..96aac1a135c 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,14 +14,14 @@ from goslideapi.goslideapi import ( ) import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DOMAIN +from .coordinator import SlideConfigEntry _LOGGER = logging.getLogger(__name__) @@ -63,7 +63,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"} except (AuthenticationFailed, DigestAuthCalcError): return {"base": "invalid_auth"} - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Exception occurred during connection test") return {"base": "unknown"} @@ -85,7 +85,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"} except (AuthenticationFailed, DigestAuthCalcError): return {"base": "invalid_auth"} - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception("Exception occurred during connection test") return {"base": "unknown"} diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py index e5311967198..cbc3e653739 100644 --- a/homeassistant/components/slide_local/coordinator.py +++ b/homeassistant/components/slide_local/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import Any from goslideapi.goslideapi import ( AuthenticationFailed, @@ -14,6 +14,7 @@ from goslideapi.goslideapi import ( GoSlideLocal as SlideLocalApi, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, @@ -31,23 +32,30 @@ from .const import DEFAULT_OFFSET, DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import SlideConfigEntry +type SlideConfigEntry = ConfigEntry[SlideCoordinator] class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get and update the latest data.""" - def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None: + config_entry: SlideConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SlideConfigEntry) -> None: """Initialize the data object.""" super().__init__( - hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15) + hass, + _LOGGER, + config_entry=config_entry, + name="Slide", + update_interval=timedelta(seconds=15), ) self.slide = SlideLocalApi() - self.api_version = entry.data[CONF_API_VERSION] - self.mac = entry.data[CONF_MAC] - self.host = entry.data[CONF_HOST] - self.password = entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + self.api_version = config_entry.data[CONF_API_VERSION] + self.mac = config_entry.data[CONF_MAC] + self.host = config_entry.data[CONF_HOST] + self.password = ( + config_entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + ) async def _async_setup(self) -> None: """Do initialization logic for Slide coordinator.""" diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py index cf04f46d139..6bb3f338cb8 100644 --- a/homeassistant/components/slide_local/cover.py +++ b/homeassistant/components/slide_local/cover.py @@ -8,11 +8,10 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity _LOGGER = logging.getLogger(__name__) @@ -23,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SlideConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover(s) for Slide platform.""" diff --git a/homeassistant/components/slide_local/diagnostics.py b/homeassistant/components/slide_local/diagnostics.py index 2655cb5fada..6a70720a14a 100644 --- a/homeassistant/components/slide_local/diagnostics.py +++ b/homeassistant/components/slide_local/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import SlideConfigEntry +from .coordinator import SlideConfigEntry TO_REDACT = [ CONF_PASSWORD, diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index f1c33f9a76f..e83924c87ee 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -15,11 +15,10 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SlideConfigEntry from .const import DOMAIN -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity PARALLEL_UPDATES = 1 @@ -28,7 +27,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: SlideConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch for Slide platform.""" @@ -47,7 +46,7 @@ class SlideSwitch(SlideEntity, SwitchEntity): def __init__(self, coordinator: SlideCoordinator) -> None: """Initialize the slide switch.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data["mac"]}-touchgo" + self._attr_unique_id = f"{coordinator.data['mac']}-touchgo" @property def is_on(self) -> bool: diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 42c50d21e75..417444961fe 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import DEFAULT_NAME, DOMAIN, PLAYER_EVENT @@ -39,7 +39,7 @@ STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SlimProto MediaPlayer(s) from Config Entry.""" slimserver: SlimServer = hass.data[DOMAIN] diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 37fb4d72284..6aae74922e4 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -72,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=sma_device_info["type"], name=sma_device_info["name"], sw_version=sma_device_info["sw_version"], + serial_number=sma_device_info["serial"], ) # Define the coordinator diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 4b3e01a79a8..3f5eb635989 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_GROUP, DOMAIN, GROUPS diff --git a/homeassistant/components/sma/diagnostics.py b/homeassistant/components/sma/diagnostics.py new file mode 100644 index 00000000000..9c17cb0d2a9 --- /dev/null +++ b/homeassistant/components/sma/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for SMA.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + entry_dict = entry.as_dict() + if "data" in entry_dict: + entry_dict["data"] = async_redact_data(entry_dict["data"], TO_REDACT) + + return { + "entry": entry_dict, + "entities": entity_states, + } diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 070320fa976..8024aad82d6 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -1,10 +1,10 @@ { "domain": "sma", "name": "SMA Solar", - "codeowners": ["@kellerza", "@rklomp"], + "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], - "requirements": ["pysma==0.7.3"] + "requirements": ["pysma==0.7.5"] } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 302c4f6b197..ffef026aaed 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -48,6 +48,12 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + "operating_status": SensorEntityDescription( + key="operating_status", + name="Operating Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), "inverter_condition": SensorEntityDescription( key="inverter_condition", name="Inverter Condition", @@ -832,7 +838,7 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMA sensors.""" sma_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 86bc225dba1..06dcaa62853 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SmappeeConfigEntry from .const import DOMAIN @@ -37,7 +37,7 @@ ICON_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: SmappeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smappee binary sensor.""" smappee_base = config_entry.runtime_data diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index 4f7a71218ab..01b69a76b28 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -6,10 +6,10 @@ from typing import Any from pysmappee import helper, mqtt import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import api from .const import ( @@ -43,7 +43,7 @@ class SmappeeFlowHandler( return logging.getLogger(__name__) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 2f9d6443568..759dfb34013 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SmappeeConfigEntry from .const import DOMAIN @@ -189,7 +189,7 @@ VOLTAGE_SENSORS: tuple[SmappeeVoltageSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SmappeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smappee sensor.""" smappee_base = config_entry.runtime_data diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index bccf816c823..cf2ddea5938 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SmappeeConfigEntry from .const import DOMAIN @@ -16,7 +16,7 @@ SWITCH_PREFIX = "Switch" async def async_setup_entry( hass: HomeAssistant, config_entry: SmappeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smappee Comfort Plugs.""" smappee_base = config_entry.runtime_data diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 80fc79671b5..c6e18bf43c1 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, UnitOfEnergy from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -30,7 +30,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smart Meter Texas sensors.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] diff --git a/homeassistant/components/smart_rollos/__init__.py b/homeassistant/components/smart_rollos/__init__.py new file mode 100644 index 00000000000..d4bb8c7fb1b --- /dev/null +++ b/homeassistant/components/smart_rollos/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Smart Rollos.""" diff --git a/homeassistant/components/smart_rollos/manifest.json b/homeassistant/components/smart_rollos/manifest.json new file mode 100644 index 00000000000..f093f740bd6 --- /dev/null +++ b/homeassistant/components/smart_rollos/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "smart_rollos", + "name": "Smart Rollos", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index bcc752ff173..b7850bc9333 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,428 +2,206 @@ from __future__ import annotations -import asyncio -from collections.abc import Iterable -from http import HTTPStatus -import importlib +from dataclasses import dataclass import logging +from typing import TYPE_CHECKING, cast -from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError -from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import Attribute, Capability, SmartThings +from aiohttp import ClientError +from pysmartthings import ( + Attribute, + Capability, + Device, + Scene, + SmartThings, + SmartThingsAuthenticationFailedError, + Status, +) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_loaded_integration -from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) -from .config_flow import SmartThingsFlowHandler # noqa: F401 -from .const import ( - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, - TOKEN_REFRESH_INTERVAL, -) -from .smartapp import ( - format_unique_id, - setup_smartapp, - setup_smartapp_endpoint, - smartapp_sync_subscriptions, - unload_smartapp_endpoint, - validate_installed_app, - validate_webhook_requirements, -) +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +@dataclass +class SmartThingsData: + """Define an object to hold SmartThings data.""" + + devices: dict[str, FullDevice] + scenes: dict[str, Scene] + rooms: dict[str, str] + client: SmartThings -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize the SmartThings platform.""" - await setup_smartapp_endpoint(hass, False) - return True +@dataclass +class FullDevice: + """Define an object to hold device data.""" + + device: Device + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle migration of a previous version config entry. +type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] - A config entry created under a previous version must go through the - integration setup again so we can properly retrieve the needed data - elements. Force this by removing the entry and triggering a new flow. - """ - # Remove the entry which will invoke the callback to delete the app. - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - # Return False because it could not be migrated. - return False +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, +] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) -> bool: """Initialize config entry which represents an installed SmartApp.""" - # For backwards compat - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, - unique_id=format_unique_id( - entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] - ), - ) + # The oauth smartthings entry will have a token, older ones are version 3 + # after migration but still require reauthentication + if CONF_TOKEN not in entry.data: + raise ConfigEntryAuthFailed("Config entry missing token") + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) - if not validate_webhook_requirements(hass): - _LOGGER.warning( - "The 'base_url' of the 'http' integration must be configured and start with" - " 'https://'" - ) - return False - - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) - - # Ensure platform modules are loaded since the DeviceBroker will - # import them below and we want them to be cached ahead of time - # so the integration does not do blocking I/O in the event loop - # to import the modules. - await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) - - remove_entry = False try: - # See if the app is already setup. This occurs when there are - # installs in multiple SmartThings locations (valid use-case) - manager = hass.data[DOMAIN][DATA_MANAGER] - smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) - if not smart_app: - # Validate and setup the app. - app = await api.app(entry.data[CONF_APP_ID]) - smart_app = setup_smartapp(hass, app) + await session.async_ensure_token_valid() + except ClientError as err: + raise ConfigEntryNotReady from err - # Validate and retrieve the installed app. - installed_app = await validate_installed_app( - api, entry.data[CONF_INSTALLED_APP_ID] - ) + client = SmartThings(session=async_get_clientsession(hass)) - # Get scenes - scenes = await async_get_entry_scenes(entry, api) + async def _refresh_token() -> str: + await session.async_ensure_token_valid() + token = session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token - # Get SmartApp token to sync subscriptions - token = await api.generate_tokens( - entry.data[CONF_CLIENT_ID], - entry.data[CONF_CLIENT_SECRET], - entry.data[CONF_REFRESH_TOKEN], - ) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} - ) + client.refresh_token_function = _refresh_token - # Get devices and their current status - devices = await api.devices(location_ids=[installed_app.location_id]) + device_status: dict[str, FullDevice] = {} + try: + rooms = { + room.room_id: room.name + for room in await client.get_rooms(location_id=entry.data[CONF_LOCATION_ID]) + } + devices = await client.get_devices() + for device in devices: + status = process_status(await client.get_device_status(device.device_id)) + device_status[device.device_id] = FullDevice(device=device, status=status) + except SmartThingsAuthenticationFailedError as err: + raise ConfigEntryAuthFailed from err - async def retrieve_device_status(device): - try: - await device.status.refresh() - except ClientResponseError: - _LOGGER.debug( - ( - "Unable to update status for device: %s (%s), the device will" - " be excluded" + device_registry = dr.async_get(hass) + for dev in device_status.values(): + for component in dev.device.components: + if component.id == MAIN and Capability.BRIDGE in component.capabilities: + assert dev.device.hub + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, dev.device.device_id)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address) + }, + name=dev.device.label, + sw_version=dev.device.hub.firmware_version, + model=dev.device.hub.hardware_type, + suggested_area=( + rooms.get(dev.device.room_id) if dev.device.room_id else None ), - device.label, - device.device_id, - exc_info=True, ) - devices.remove(device) + scenes = { + scene.scene_id: scene + for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) + } - await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy())) + entry.runtime_data = SmartThingsData( + devices={ + device_id: device + for device_id, device in device_status.items() + if MAIN in device.status + }, + client=client, + scenes=scenes, + rooms=rooms, + ) - # Sync device subscriptions - await smartapp_sync_subscriptions( - hass, - token.access_token, - installed_app.location_id, - installed_app.installed_app_id, - devices, - ) - - # Setup device broker - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): - # DeviceBroker has a side effect of importing platform - # modules when its created. In the future this should be - # refactored to not do this. - broker = await hass.async_add_import_executor_job( - DeviceBroker, hass, entry, token, smart_app, devices, scenes - ) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker - - except ClientResponseError as ex: - if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - _LOGGER.exception( - ( - "Unable to setup configuration entry '%s' - please reconfigure the" - " integration" - ), - entry.title, - ) - remove_entry = True - else: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex - except (ClientConnectionError, RuntimeWarning) as ex: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex - - if remove_entry: - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - return False + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] + ), + "smartthings_webhook", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + device_id = next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ) + if device_id in device_status: + continue + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + return True -async def async_get_entry_scenes(entry: ConfigEntry, api): - """Get the scenes within an integration.""" - try: - return await api.scenes(location_id=entry.data[CONF_LOCATION_ID]) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.exception( - ( - "Unable to load scenes for configuration entry '%s' because the" - " access token does not have the required access" - ), - entry.title, - ) - else: - raise - return [] - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SmartThingsConfigEntry +) -> bool: """Unload a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) - if broker: - broker.disconnect() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Perform clean-up when entry is being removed.""" - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle config entry migration.""" - # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error. - installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - try: - await api.delete_installed_app(installed_app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug( - "Installed app %s has already been removed", - installed_app_id, - exc_info=True, - ) - else: - raise - _LOGGER.debug("Removed installed app %s", installed_app_id) - - # Remove the app if not referenced by other entries, which if already - # removed raises a HTTPStatus.FORBIDDEN error. - all_entries = hass.config_entries.async_entries(DOMAIN) - app_id = entry.data[CONF_APP_ID] - app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) - if app_count > 1: - _LOGGER.debug( - ( - "App %s was not removed because it is in use by other configuration" - " entries" - ), - app_id, - ) - return - # Remove the app - try: - await api.delete_app(app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug("App %s has already been removed", app_id, exc_info=True) - else: - raise - _LOGGER.debug("Removed app %s", app_id) - - if len(all_entries) == 1: - await unload_smartapp_endpoint(hass) - - -class DeviceBroker: - """Manages an individual SmartThings config entry.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - token, - smart_app, - devices: Iterable, - scenes: Iterable, - ) -> None: - """Create a new instance of the DeviceBroker.""" - self._hass = hass - self._entry = entry - self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - self._smart_app = smart_app - self._token = token - self._event_disconnect = None - self._regenerate_token_remove = None - self._assignments = self._assign_capabilities(devices) - self.devices = {device.device_id: device for device in devices} - self.scenes = {scene.scene_id: scene for scene in scenes} - - def _assign_capabilities(self, devices: Iterable): - """Assign platforms to capabilities.""" - assignments = {} - for device in devices: - capabilities = device.capabilities.copy() - slots = {} - for platform in PLATFORMS: - platform_module = importlib.import_module( - f".{platform}", self.__module__ - ) - if not hasattr(platform_module, "get_capabilities"): - continue - assigned = platform_module.get_capabilities(capabilities) - if not assigned: - continue - # Draw-down capabilities and set slot assignment - for capability in assigned: - if capability not in capabilities: - continue - capabilities.remove(capability) - slots[capability] = platform - assignments[device.device_id] = slots - return assignments - - def connect(self): - """Connect handlers/listeners for device/lifecycle events.""" - - # Setup interval to regenerate the refresh token on a periodic basis. - # Tokens expire in 30 days and once expired, cannot be recovered. - async def regenerate_refresh_token(now): - """Generate a new refresh token and update the config entry.""" - await self._token.refresh( - self._entry.data[CONF_CLIENT_ID], - self._entry.data[CONF_CLIENT_SECRET], - ) - self._hass.config_entries.async_update_entry( - self._entry, - data={ - **self._entry.data, - CONF_REFRESH_TOKEN: self._token.refresh_token, - }, - ) - _LOGGER.debug( - "Regenerated refresh token for installed app: %s", - self._installed_app_id, - ) - - self._regenerate_token_remove = async_track_time_interval( - self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL + if entry.version < 3: + # We keep the old data around, so we can use that to clean up the webhook in the future + hass.config_entries.async_update_entry( + entry, version=3, data={OLD_DATA: dict(entry.data)} ) - # Connect handler to incoming device events - self._event_disconnect = self._smart_app.connect_event(self._event_handler) + return True - def disconnect(self): - """Disconnects handlers/listeners for device/lifecycle events.""" - if self._regenerate_token_remove: - self._regenerate_token_remove() - if self._event_disconnect: - self._event_disconnect() - def get_assigned(self, device_id: str, platform: str): - """Get the capabilities assigned to the platform.""" - slots = self._assignments.get(device_id, {}) - return [key for key, value in slots.items() if value == platform] - - def any_assigned(self, device_id: str, platform: str): - """Return True if the platform has any assigned capabilities.""" - slots = self._assignments.get(device_id, {}) - return any(value for value in slots.values() if value == platform) - - async def _event_handler(self, req, resp, app): - """Broker for incoming events.""" - # Do not process events received from a different installed app - # under the same parent SmartApp (valid use-scenario) - if req.installed_app_id != self._installed_app_id: - return - - updated_devices = set() - for evt in req.events: - if evt.event_type != EVENT_TYPE_DEVICE: - continue - if not (device := self.devices.get(evt.device_id)): - continue - device.status.apply_attribute_update( - evt.component_id, - evt.capability, - evt.attribute, - evt.value, - data=evt.data, - ) - - # Fire events for buttons - if ( - evt.capability == Capability.button - and evt.attribute == Attribute.button - ): - data = { - "component_id": evt.component_id, - "device_id": evt.device_id, - "location_id": evt.location_id, - "value": evt.value, - "name": device.label, - "data": evt.data, - } - self._hass.bus.async_fire(EVENT_BUTTON, data) - _LOGGER.debug("Fired button event: %s", data) - else: - data = { - "location_id": evt.location_id, - "device_id": evt.device_id, - "component_id": evt.component_id, - "capability": evt.capability, - "attribute": evt.attribute, - "value": evt.value, - "data": evt.data, - } - _LOGGER.debug("Push update received: %s", data) - - updated_devices.add(device.device_id) - - async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) +def process_status( + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], +) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: + """Remove disabled capabilities from status.""" + if (main_component := status.get("main")) is None or ( + disabled_capabilities_capability := main_component.get( + Capability.CUSTOM_DISABLED_CAPABILITIES + ) + ) is None: + return status + disabled_capabilities = cast( + list[Capability | str], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] + return status diff --git a/homeassistant/components/smartthings/application_credentials.py b/homeassistant/components/smartthings/application_credentials.py new file mode 100644 index 00000000000..1e637c6bd12 --- /dev/null +++ b/homeassistant/components/smartthings/application_credentials.py @@ -0,0 +1,64 @@ +"""Application credentials platform for SmartThings.""" + +from json import JSONDecodeError +import logging +from typing import cast + +from aiohttp import BasicAuth, ClientError + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return auth implementation.""" + return SmartThingsOAuth2Implementation( + hass, + DOMAIN, + credential, + authorization_server=AuthorizationServer( + authorize_url="https://api.smartthings.com/oauth/authorize", + token_url="https://auth-global.api.smartthings.com/oauth/token", + ), + ) + + +class SmartThingsOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + + resp = await session.post( + self.token_url, + data=data, + auth=BasicAuth(self.client_id, self.client_secret), + ) + if resp.status >= 400: + try: + error_response = await resp.json() + except (ClientError, JSONDecodeError): + error_response = {} + error_code = error_response.get("error", "unknown") + error_description = error_response.get("error_description", "unknown error") + _LOGGER.error( + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, + ) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 611473b011d..080a90440be 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,84 +2,152 @@ from __future__ import annotations -from collections.abc import Sequence +from dataclasses import dataclass -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity -CAPABILITY_TO_ATTRIB = { - Capability.acceleration_sensor: Attribute.acceleration, - Capability.contact_sensor: Attribute.contact, - Capability.filter_status: Attribute.filter_status, - Capability.motion_sensor: Attribute.motion, - Capability.presence_sensor: Attribute.presence, - Capability.sound_sensor: Attribute.sound, - Capability.tamper_alert: Attribute.tamper, - Capability.valve: Attribute.valve, - Capability.water_sensor: Attribute.water, -} -ATTRIB_TO_CLASS = { - Attribute.acceleration: BinarySensorDeviceClass.MOVING, - Attribute.contact: BinarySensorDeviceClass.OPENING, - Attribute.filter_status: BinarySensorDeviceClass.PROBLEM, - Attribute.motion: BinarySensorDeviceClass.MOTION, - Attribute.presence: BinarySensorDeviceClass.PRESENCE, - Attribute.sound: BinarySensorDeviceClass.SOUND, - Attribute.tamper: BinarySensorDeviceClass.PROBLEM, - Attribute.valve: BinarySensorDeviceClass.OPENING, - Attribute.water: BinarySensorDeviceClass.MOISTURE, -} -ATTRIB_TO_ENTTIY_CATEGORY = { - Attribute.tamper: EntityCategory.DIAGNOSTIC, + +@dataclass(frozen=True, kw_only=True) +class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe a SmartThings binary sensor entity.""" + + is_on_key: str + + +CAPABILITY_TO_SENSORS: dict[ + Capability, dict[Attribute, SmartThingsBinarySensorEntityDescription] +] = { + Capability.ACCELERATION_SENSOR: { + Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( + key=Attribute.ACCELERATION, + translation_key="acceleration", + device_class=BinarySensorDeviceClass.MOVING, + is_on_key="active", + ) + }, + Capability.CONTACT_SENSOR: { + Attribute.CONTACT: SmartThingsBinarySensorEntityDescription( + key=Attribute.CONTACT, + device_class=BinarySensorDeviceClass.DOOR, + is_on_key="open", + ) + }, + Capability.FILTER_STATUS: { + Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.FILTER_STATUS, + translation_key="filter_status", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="replace", + ) + }, + Capability.MOTION_SENSOR: { + Attribute.MOTION: SmartThingsBinarySensorEntityDescription( + key=Attribute.MOTION, + device_class=BinarySensorDeviceClass.MOTION, + is_on_key="active", + ) + }, + Capability.PRESENCE_SENSOR: { + Attribute.PRESENCE: SmartThingsBinarySensorEntityDescription( + key=Attribute.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + is_on_key="present", + ) + }, + Capability.SOUND_SENSOR: { + Attribute.SOUND: SmartThingsBinarySensorEntityDescription( + key=Attribute.SOUND, + device_class=BinarySensorDeviceClass.SOUND, + is_on_key="detected", + ) + }, + Capability.TAMPER_ALERT: { + Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( + key=Attribute.TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + is_on_key="detected", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, + Capability.VALVE: { + Attribute.VALVE: SmartThingsBinarySensorEntityDescription( + key=Attribute.VALVE, + translation_key="valve", + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, + Capability.WATER_SENSOR: { + Attribute.WATER: SmartThingsBinarySensorEntityDescription( + key=Attribute.WATER, + device_class=BinarySensorDeviceClass.MOISTURE, + is_on_key="wet", + ) + }, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add binary sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - sensors = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "binary_sensor"): - attrib = CAPABILITY_TO_ATTRIB[capability] - sensors.append(SmartThingsBinarySensor(device, attrib)) - async_add_entities(sensors) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities - ] + entry_data = entry.runtime_data + async_add_entities( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, + ) + for device in entry_data.devices.values() + for capability, attribute_map in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, description in attribute_map.items() + ) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): """Define a SmartThings Binary Sensor.""" - def __init__(self, device, attribute): + entity_description: SmartThingsBinarySensorEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsBinarySensorEntityDescription, + rooms: dict[str, str], + capability: Capability, + attribute: Attribute, + ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, rooms, {capability}) self._attribute = attribute - self._attr_name = f"{device.label} {attribute}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = ATTRIB_TO_CLASS[attribute] - self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) + self.capability = capability + self.entity_description = entity_description + self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._device.status.is_on(self._attribute) + return ( + self.get_attribute_value(self.capability, self._attribute) + == self.entity_description.is_on_key + ) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index d9535272295..b2f8819601c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -3,17 +3,15 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, Sequence import logging from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -23,12 +21,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" @@ -97,124 +95,111 @@ UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) +AC_CAPABILITIES = [ + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.SWITCH, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +] + +THERMOSTAT_CAPABILITIES = [ + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +] + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climate entities for a config entry.""" - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, + entry_data = entry.runtime_data + entities: list[ClimateEntity] = [ + SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) + for device in entry_data.devices.values() + if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] - - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[ClimateEntity] = [] - for device in broker.devices.values(): - if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): - continue - if all(capability in device.capabilities for capability in ac_capabilities): - entities.append(SmartThingsAirConditioner(device)) - else: - entities.append(SmartThingsThermostat(device)) - async_add_entities(entities, True) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.thermostat, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_fan_mode, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - ] - # Can have this legacy/deprecated capability - if Capability.thermostat in capabilities: - return supported - # Or must have all of these thermostat capabilities - thermostat_capabilities = [ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ] - if all(capability in capabilities for capability in thermostat_capabilities): - return supported - # Or must have all of these A/C capabilities - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - ] - if all(capability in capabilities for capability in ac_capabilities): - return supported - return None + entities.extend( + SmartThingsThermostat(entry_data.client, entry_data.rooms, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES + ) + ) + async_add_entities(entities) class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" - def __init__(self, device): - """Init the class.""" - super().__init__(device) - self._attr_supported_features = self._determine_features() - self._hvac_mode = None - self._hvac_modes = None + _attr_name = None - def _determine_features(self): + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: + """Init the class.""" + super().__init__( + client, + device, + rooms, + { + Capability.THERMOSTAT_FAN_MODE, + Capability.THERMOSTAT_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_OPERATING_STATE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + }, + ) + self._attr_supported_features = self._determine_features() + + def _determine_features(self) -> ClimateEntityFeature: flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability( - Capability.thermostat_fan_mode, Capability.thermostat + if self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE ): flags |= ClimateEntityFeature.FAN_MODE return flags async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_thermostat_fan_mode(fan_mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" - mode = STATE_TO_MODE[hvac_mode] - await self._device.set_thermostat_mode(mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + argument=STATE_TO_MODE[hvac_mode], + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new operation mode and target temperatures.""" + hvac_mode = self.hvac_mode # Operation state if operation_state := kwargs.get(ATTR_HVAC_MODE): - mode = STATE_TO_MODE[operation_state] - await self._device.set_thermostat_mode(mode, set_status=True) - await self.async_update() + await self.async_set_hvac_mode(operation_state) + hvac_mode = operation_state # Heat/cool setpoint heating_setpoint = None cooling_setpoint = None - if self.hvac_mode == HVACMode.HEAT: + if hvac_mode == HVACMode.HEAT: heating_setpoint = kwargs.get(ATTR_TEMPERATURE) - elif self.hvac_mode == HVACMode.COOL: + elif hvac_mode == HVACMode.COOL: cooling_setpoint = kwargs.get(ATTR_TEMPERATURE) else: heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW) @@ -222,137 +207,152 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): tasks = [] if heating_setpoint is not None: tasks.append( - self._device.set_heating_setpoint( - round(heating_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + argument=round(heating_setpoint, 3), ) ) if cooling_setpoint is not None: tasks.append( - self._device.set_cooling_setpoint( - round(cooling_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=round(cooling_setpoint, 3), ) ) await asyncio.gather(*tasks) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: - """Update the attributes of the climate device.""" - thermostat_mode = self._device.status.thermostat_mode - self._hvac_mode = MODE_TO_STATE.get(thermostat_mode) - if self._hvac_mode is None: - _LOGGER.debug( - "Device %s (%s) returned an invalid hvac mode: %s", - self._device.label, - self._device.device_id, - thermostat_mode, - ) - - modes = set() - supported_modes = self._device.status.supported_thermostat_modes - if isinstance(supported_modes, Iterable): - for mode in supported_modes: - if (state := MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - ( - "Device %s (%s) returned an invalid supported thermostat" - " mode: %s" - ), - self._device.label, - self._device.device_id, - mode, - ) - else: - _LOGGER.debug( - "Device %s (%s) returned invalid supported thermostat modes: %s", - self._device.label, - self._device.device_id, - supported_modes, - ) - self._hvac_modes = list(modes) - @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" - return self._device.status.humidity + if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT): + return self.get_attribute_value( + Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY + ) + return None @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" - return self._device.status.thermostat_fan_mode + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE + ) @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_thermostat_fan_modes + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES + ) @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return OPERATING_STATE_TO_ACTION.get( - self._device.status.thermostat_operating_state + self.get_attribute_value( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + ) ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return self._hvac_mode + return MODE_TO_STATE.get( + self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE + ) + ) @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return self._hvac_modes + return [ + state + for mode in self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ] @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) if self.hvac_mode == HVACMode.HEAT: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) return None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" - _hvac_modes: list[HVACMode] + _attr_name = None + _attr_preset_mode = None - def __init__(self, device) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" - super().__init__(device) - self._hvac_modes = [] - self._attr_preset_mode = None + super().__init__( + client, + device, + rooms, + { + Capability.AIR_CONDITIONER_MODE, + Capability.SWITCH, + Capability.FAN_OSCILLATION_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + }, + ) + self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() - self._attr_swing_modes = self._determine_swing_modes() + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): + self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: @@ -362,7 +362,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability(Capability.fan_oscillation_mode): + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): features |= ClimateEntityFeature.SWING_MODE if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: features |= ClimateEntityFeature.PRESET_MODE @@ -370,14 +370,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(fan_mode, set_status=True) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" @@ -386,23 +383,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return tasks = [] # Turn on the device if it's off before setting mode. - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" # The conversion make the mode change working # The conversion is made only for device that wrongly has capability "wind" instead "fan_only" if hvac_mode == HVACMode.FAN_ONLY: - supported_modes = self._device.status.supported_ac_modes - if WIND in supported_modes: + if WIND in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ): mode = WIND - tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True)) + tasks.append( + self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=mode, + ) + ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -410,53 +411,44 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): # operation mode if operation_mode := kwargs.get(ATTR_HVAC_MODE): if operation_mode == HVACMode.OFF: - tasks.append(self._device.switch_off(set_status=True)) + tasks.append(self.async_turn_off()) else: - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if ( + self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + == "off" + ): + tasks.append(self.async_turn_on()) tasks.append(self.async_set_hvac_mode(operation_mode)) # temperature tasks.append( - self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True) + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_turn_on(self) -> None: """Turn device on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self) -> None: """Turn device off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the calculated fields of the AC.""" - modes = {HVACMode.OFF} - for mode in self._device.status.supported_ac_modes: - if (state := AC_MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - "Device %s (%s) returned an invalid supported AC mode: %s", - self._device.label, - self._device.device_id, - mode, - ) - self._hvac_modes = list(modes) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -465,100 +457,114 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ - attributes = [ - "drlc_status_duration", - "drlc_status_level", - "drlc_status_start", - "drlc_status_override", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + drlc_status = self.get_attribute_value( + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, + ) + return { + "drlc_status_duration": drlc_status["duration"], + "drlc_status_level": drlc_status["drlcLevel"], + "drlc_status_start": drlc_status["start"], + "drlc_status_override": drlc_status["override"], + } @property def fan_mode(self) -> str: """Return the fan setting.""" - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - if not self._device.status.switch: + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": return HVACMode.OFF - return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return self._hvac_modes + return AC_MODE_TO_STATE.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - supported_swings = None - supported_modes = self._device.status.attributes[ - Attribute.supported_fan_oscillation_modes - ][0] - if supported_modes is not None: - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] - return supported_swings + if ( + supported_modes := self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ) + ) is None: + return None + return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes] async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" - fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] - await self._device.set_fan_oscillation_mode(fan_oscillation_mode) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + argument=SWING_TO_FAN_OSCILLATION[swing_mode], + ) @property def swing_mode(self) -> str: """Return the swing setting.""" return FAN_OSCILLATION_TO_SWING.get( - self._device.status.fan_oscillation_mode, SWING_OFF + self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE + ), + SWING_OFF, ) def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes: list | None = self._device.status.attributes[ - "supportedAcOptionalMode" - ].value - if supported_modes and WINDFREE in supported_modes: - return [WINDFREE] + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + supported_modes = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.SUPPORTED_AC_OPTIONAL_MODE, + ) + if supported_modes and WINDFREE in supported_modes: + return [WINDFREE] return None async def async_set_preset_mode(self, preset_mode: str) -> None: """Set special modes (currently only windFree is supported).""" - result = await self._device.command( - "main", - "custom.airConditionerOptionalMode", - "setAcOptionalMode", - [preset_mode], + await self.execute_device_command( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + argument=preset_mode, ) - if result: - self._device.status.update_attribute_value("acOptionalMode", preset_mode) - self._attr_preset_mode = preset_mode - - self.async_write_ha_state() + def _determine_hvac_modes(self) -> list[HVACMode]: + """Determine the supported HVAC modes.""" + modes = [HVACMode.OFF] + modes.extend( + state + for mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ) + return modes diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 081f833787e..02b11b190c9 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,259 +1,85 @@ """Config flow to configure SmartThings.""" -from http import HTTPStatus +from collections.abc import Mapping import logging from typing import Any -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError, AppOAuth, SmartThings -from pysmartthings.installedapp import format_install_url -import voluptuous as vol +from pysmartthings import SmartThings -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import ( - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DOMAIN, - VAL_UID_MATCHER, -) -from .smartapp import ( - create_app, - find_app, - format_unique_id, - get_webhook_url, - setup_smartapp, - setup_smartapp_endpoint, - update_app, - validate_webhook_requirements, -) +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES _LOGGER = logging.getLogger(__name__) -class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): +class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" - VERSION = 2 + VERSION = 3 + DOMAIN = DOMAIN - api: SmartThings - app_id: str - location_id: str + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - def __init__(self) -> None: - """Create a new instance of the flow handler.""" - self.access_token: str | None = None - self.oauth_client_secret = None - self.oauth_client_id = None - self.installed_app_id = None - self.refresh_token = None - self.endpoints_initialized = False + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(REQUESTED_SCOPES)} - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Occurs when a previously entry setup fails and is re-initiated.""" - return await self.async_step_user(import_data) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for SmartThings.""" + if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): + return self.async_abort(reason="missing_scopes") + client = SmartThings(session=async_get_clientsession(self.hass)) + client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + locations = await client.get_locations() + location = locations[0] + # We pick to use the location id as unique id rather than the installed app id + # as the installed app id could change with the right settings in the SmartApp + # or the app used to sign in changed for any reason. + await self.async_set_unique_id(location.location_id) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Validate and confirm webhook setup.""" - if not self.endpoints_initialized: - self.endpoints_initialized = True - await setup_smartapp_endpoint( - self.hass, len(self._async_current_entries()) == 0 + return self.async_create_entry( + title=location.name, + data={**data, CONF_LOCATION_ID: location.location_id}, ) - webhook_url = get_webhook_url(self.hass) - # Abort if the webhook is invalid - if not validate_webhook_requirements(self.hass): - return self.async_abort( - reason="invalid_webhook_url", - description_placeholders={ - "webhook_url": webhook_url, - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), + if (entry := self._get_reauth_entry()) and CONF_TOKEN not in entry.data: + if entry.data[OLD_DATA][CONF_LOCATION_ID] != location.location_id: + return self.async_abort(reason="reauth_location_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + **data, + CONF_LOCATION_ID: location.location_id, }, + unique_id=location.location_id, ) - - # Show the confirmation - if user_input is None: - return self.async_show_form( - step_id="user", - description_placeholders={"webhook_url": webhook_url}, - ) - - # Show the next screen - return await self.async_step_pat() - - async def async_step_pat( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Get the Personal Access Token and validate it.""" - errors: dict[str, str] = {} - if user_input is None or CONF_ACCESS_TOKEN not in user_input: - return self._show_step_pat(errors) - - self.access_token = user_input[CONF_ACCESS_TOKEN] - - # Ensure token is a UUID - if not VAL_UID_MATCHER.match(self.access_token): - errors[CONF_ACCESS_TOKEN] = "token_invalid_format" - return self._show_step_pat(errors) - - # Setup end-point - self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) - try: - app = await find_app(self.hass, self.api) - if app: - await app.refresh() # load all attributes - await update_app(self.hass, app) - # Find an existing entry to copy the oauth client - existing = next( - ( - entry - for entry in self._async_current_entries() - if entry.data[CONF_APP_ID] == app.app_id - ), - None, - ) - if existing: - self.oauth_client_id = existing.data[CONF_CLIENT_ID] - self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET] - else: - # Get oauth client id/secret by regenerating it - app_oauth = AppOAuth(app.app_id) - app_oauth.client_name = APP_OAUTH_CLIENT_NAME - app_oauth.scope.extend(APP_OAUTH_SCOPES) - client = await self.api.generate_app_oauth(app_oauth) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - else: - app, client = await create_app(self.hass, self.api) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - setup_smartapp(self.hass, app) - self.app_id = app.app_id - - except APIResponseError as ex: - if ex.is_target_error(): - errors["base"] = "webhook_error" - else: - errors["base"] = "app_setup_error" - _LOGGER.exception( - "API error setting up the SmartApp: %s", ex.raw_error_response - ) - return self._show_step_pat(errors) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "token_unauthorized" - _LOGGER.debug( - "Unauthorized error received setting up SmartApp", exc_info=True - ) - elif ex.status == HTTPStatus.FORBIDDEN: - errors[CONF_ACCESS_TOKEN] = "token_forbidden" - _LOGGER.debug( - "Forbidden error received setting up SmartApp", exc_info=True - ) - else: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - except Exception: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - - return await self.async_step_select_location() - - async def async_step_select_location( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Ask user to select the location to setup.""" - if user_input is None or CONF_LOCATION_ID not in user_input: - # Get available locations - existing_locations = [ - entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries() - ] - locations = await self.api.locations() - locations_options = { - location.location_id: location.name - for location in locations - if location.location_id not in existing_locations - } - if not locations_options: - return self.async_abort(reason="no_available_locations") - - return self.async_show_form( - step_id="select_location", - data_schema=vol.Schema( - {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)} - ), - ) - - self.location_id = user_input[CONF_LOCATION_ID] - await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) - return await self.async_step_authorize() - - async def async_step_authorize( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Wait for the user to authorize the app installation.""" - user_input = {} if user_input is None else user_input - self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) - self.refresh_token = user_input.get(CONF_REFRESH_TOKEN) - if self.installed_app_id is None: - # Launch the external setup URL - url = format_install_url(self.app_id, self.location_id) - return self.async_external_step(step_id="authorize", url=url) - - return self.async_external_step_done(next_step_id="install") - - def _show_step_pat(self, errors): - if self.access_token is None: - # Get the token from an existing entry to make it easier to setup multiple locations. - self.access_token = next( - ( - entry.data.get(CONF_ACCESS_TOKEN) - for entry in self._async_current_entries() - ), - None, - ) - - return self.async_show_form( - step_id="pat", - data_schema=vol.Schema( - {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} - ), - errors=errors, - description_placeholders={ - "token_url": "https://account.smartthings.com/tokens", - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), - }, + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=data ) - async def async_step_install( + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon migration of old entries.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Create a config entry at completion of a flow and authorization of the app.""" - data = { - CONF_ACCESS_TOKEN: self.access_token, - CONF_REFRESH_TOKEN: self.refresh_token, - CONF_CLIENT_ID: self.oauth_client_id, - CONF_CLIENT_SECRET: self.oauth_client_secret, - CONF_LOCATION_ID: self.location_id, - CONF_APP_ID: self.app_id, - CONF_INSTALLED_APP_ID: self.installed_app_id, - } - - location = await self.api.location(data[CONF_LOCATION_ID]) - - return self.async_create_entry(title=location.name, data=data) + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + ) + return await self.async_step_user() diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index e50837697e7..23fd48a4e1e 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,15 +1,27 @@ """Constants used by the SmartThings component and platforms.""" -from datetime import timedelta -import re - -from homeassistant.const import Platform - DOMAIN = "smartthings" -APP_OAUTH_CLIENT_NAME = "Home Assistant" -APP_OAUTH_SCOPES = ["r:devices:*"] -APP_NAME_PREFIX = "homeassistant." +SCOPES = [ + "r:devices:*", + "w:devices:*", + "x:devices:*", + "r:hubs:*", + "r:locations:*", + "w:locations:*", + "x:locations:*", + "r:scenes:*", + "x:scenes:*", + "r:rules:*", + "w:rules:*", + "sse", +] + +REQUESTED_SCOPES = [ + *SCOPES, + "r:installedapps", + "w:installedapps", +] CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" @@ -18,41 +30,5 @@ CONF_INSTANCE_ID = "instance_id" CONF_LOCATION_ID = "location_id" CONF_REFRESH_TOKEN = "refresh_token" -DATA_MANAGER = "manager" -DATA_BROKERS = "brokers" -EVENT_BUTTON = "smartthings.button" - -SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update" -SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" - -SETTINGS_INSTANCE_ID = "hassInstanceId" - -SUBSCRIPTION_WARNING_LIMIT = 40 - -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 - -# Ordered 'specific to least-specific platform' in order for capabilities -# to be drawn-down and represented by the most appropriate platform. -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CLIMATE, - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SCENE, - Platform.SENSOR, - Platform.SWITCH, -] - -IGNORED_CAPABILITIES = [ - "execute", - "healthCheck", - "ocf", -] - -TOKEN_REFRESH_INTERVAL = timedelta(days=14) - -VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" -VAL_UID_MATCHER = re.compile(VAL_UID) +MAIN = "main" +OLD_DATA = "old_data" diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 55e86bd582e..564de8443b1 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -2,25 +2,23 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverDeviceClass, CoverEntity, CoverEntityFeature, CoverState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity VALUE_TO_STATE = { @@ -32,114 +30,107 @@ VALUE_TO_STATE = { "unknown": None, } +CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add covers for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsCover(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, COVER_DOMAIN) - ], - True, + SmartThingsCover( + entry_data.client, device, entry_data.rooms, Capability(capability) + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - min_required = [ - Capability.door_control, - Capability.garage_door_control, - Capability.window_shade, - ] - # Must have one of the min_required - if any(capability in capabilities for capability in min_required): - # Return all capabilities supported/consumed - return [ - *min_required, - Capability.battery, - Capability.switch_level, - Capability.window_shade_level, - ] - - return None - - class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" - def __init__(self, device): + _attr_name = None + _state: CoverState | None = None + + def __init__( + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capability: Capability, + ) -> None: """Initialize the cover class.""" - super().__init__(device) - self._current_cover_position = None - self._state = None + super().__init__( + client, + device, + rooms, + { + capability, + Capability.BATTERY, + Capability.WINDOW_SHADE_LEVEL, + Capability.SWITCH_LEVEL, + }, + ) + self.capability = capability self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if ( - Capability.switch_level in device.capabilities - or Capability.window_shade_level in device.capabilities - ): + if self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self.level_capability = Capability.WINDOW_SHADE_LEVEL + self.level_command = Command.SET_SHADE_LEVEL + else: + self.level_capability = Capability.SWITCH_LEVEL + self.level_command = Command.SET_LEVEL + if self.supports_capability( + Capability.SWITCH_LEVEL + ) or self.supports_capability(Capability.WINDOW_SHADE_LEVEL): self._attr_supported_features |= CoverEntityFeature.SET_POSITION - if Capability.door_control in device.capabilities: + if self.supports_capability(Capability.DOOR_CONTROL): self._attr_device_class = CoverDeviceClass.DOOR - elif Capability.window_shade in device.capabilities: + elif self.supports_capability(Capability.WINDOW_SHADE): self._attr_device_class = CoverDeviceClass.SHADE - elif Capability.garage_door_control in device.capabilities: - self._attr_device_class = CoverDeviceClass.GARAGE async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - # Same command for all 3 supported capabilities - await self._device.close(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.CLOSE) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - # Same for all capability types - await self._device.open(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.OPEN) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if not self.supported_features & CoverEntityFeature.SET_POSITION: - return - # Do not set_status=True as device will report progress. - if Capability.window_shade_level in self._device.capabilities: - await self._device.set_window_shade_level( - kwargs[ATTR_POSITION], set_status=False - ) - else: - await self._device.set_level(kwargs[ATTR_POSITION], set_status=False) + await self.execute_device_command( + self.level_capability, + self.level_command, + argument=kwargs[ATTR_POSITION], + ) - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update the attrs of the cover.""" - if Capability.door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) - elif Capability.window_shade in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.window_shade) - elif Capability.garage_door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) + attribute = { + Capability.WINDOW_SHADE: Attribute.WINDOW_SHADE, + Capability.DOOR_CONTROL: Attribute.DOOR, + }[self.capability] + self._state = VALUE_TO_STATE.get( + self.get_attribute_value(self.capability, attribute) + ) - if Capability.window_shade_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.shade_level - elif Capability.switch_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.level + if self.supports_capability(Capability.SWITCH_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) self._attr_extra_state_attributes = {} - battery = self._device.status.attributes[Attribute.battery].value - if battery is not None: - self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery + if self.supports_capability(Capability.BATTERY): + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( + self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY) + ) @property def is_opening(self) -> bool: diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py new file mode 100644 index 00000000000..fc34415e419 --- /dev/null +++ b/homeassistant/components/smartthings/diagnostics.py @@ -0,0 +1,49 @@ +"""Diagnostics support for SmartThings.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from typing import Any + +from pysmartthings import DeviceEvent + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import SmartThingsConfigEntry +from .const import DOMAIN + +EVENT_WAIT_TIME = 5 + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + client = entry.runtime_data.client + device_id = next( + identifier for identifier in device.identifiers if identifier[0] == DOMAIN + )[1] + + device_status = await client.get_device_status(device_id) + + events: list[DeviceEvent] = [] + + def register_event(event: DeviceEvent) -> None: + events.append(event) + + listener = client.add_device_event_listener(device_id, register_event) + + await asyncio.sleep(EVENT_WAIT_TIME) + + listener() + + status: dict[str, Any] = {} + for component, capabilities in device_status.items(): + status[component] = {} + for capability, attributes in capabilities.items(): + status[component][capability] = {} + for attribute, value in attributes.items(): + status[component][capability][attribute] = asdict(value) + return {"events": [asdict(event) for event in events], "status": status} diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index cc63213d122..542401109ad 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,49 +2,124 @@ from __future__ import annotations -from pysmartthings.device import DeviceEntity +from typing import Any + +from pysmartthings import ( + Attribute, + Capability, + Command, + DeviceEvent, + SmartThings, + Status, +) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from . import FullDevice +from .const import DOMAIN, MAIN class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, device: DeviceEntity) -> None: + def __init__( + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capabilities: set[Capability], + ) -> None: """Initialize the instance.""" - self._device = device - self._dispatcher_remove = None - self._attr_name = device.label - self._attr_unique_id = device.device_id + self.client = client + self.capabilities = capabilities + self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { + capability: device.status[MAIN][capability] + for capability in capabilities + if capability in device.status[MAIN] + } + self.device = device + self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, device.device_id)}, - manufacturer=device.status.ocf_manufacturer_name, - model=device.status.ocf_model_number, - name=device.label, - hw_version=device.status.ocf_hardware_version, - sw_version=device.status.ocf_firmware_version, + identifiers={(DOMAIN, device.device.device_id)}, + name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), ) + if device.device.parent_device_id: + self._attr_device_info["via_device"] = ( + DOMAIN, + device.device.parent_device_id, + ) + if (ocf := device.device.ocf) is not None: + self._attr_device_info.update( + { + "manufacturer": ocf.manufacturer_name, + "model": ocf.model_number.split("|")[0], + "hw_version": ocf.hardware_version, + "sw_version": ocf.firmware_version, + } + ) + if (viper := device.device.viper) is not None: + self._attr_device_info.update( + { + "manufacturer": viper.manufacturer_name, + "model": viper.model_name, + "hw_version": viper.hardware_version, + "sw_version": viper.software_version, + } + ) - async def async_added_to_hass(self): - """Device added to hass.""" + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + for capability in self._internal_state: + self.async_on_remove( + self.client.add_device_capability_event_listener( + self.device.device.device_id, + MAIN, + capability, + self._update_handler, + ) + ) + self._update_attr() - async def async_update_state(devices): - """Update device state.""" - if self._device.device_id in devices: - await self.async_update_ha_state(True) + def _update_handler(self, event: DeviceEvent) -> None: + self._internal_state[event.capability][event.attribute].value = event.value + self._internal_state[event.capability][event.attribute].data = event.data + self._handle_update() - self._dispatcher_remove = async_dispatcher_connect( - self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state + def supports_capability(self, capability: Capability) -> bool: + """Test if device supports a capability.""" + return capability in self.device.status[MAIN] + + def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: + """Get the value of a device attribute.""" + return self._internal_state[capability][attribute].value + + def _update_attr(self) -> None: + """Update the attributes.""" + + def _handle_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() + + async def execute_device_command( + self, + capability: Capability, + command: Command, + argument: int | str | list[Any] | dict[str, Any] | None = None, + ) -> None: + """Execute a command on the device.""" + kwargs = {} + if argument is not None: + kwargs["argument"] = argument + await self.client.execute_device_command( + self.device.device.device_id, capability, command, MAIN, **kwargs ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect the device when removed.""" - if self._dispatcher_remove: - self._dispatcher_remove() diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 61e30589273..9aa467cbfa8 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -2,23 +2,22 @@ from __future__ import annotations -from collections.abc import Sequence import math from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, ) from homeassistant.util.scaling import int_states_in_range -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity SPEED_RANGE = (1, 3) # off is not included @@ -26,86 +25,77 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add fans for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "fan") + SmartThingsFan(entry_data.client, entry_data.rooms, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any( + capability in device.status[MAIN] + for capability in ( + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + ) + ) + and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - - # MUST support switch as we need a way to turn it on and off - if Capability.switch not in capabilities: - return None - - # These are all optional but at least one must be supported - optional = [ - Capability.air_conditioner_fan_mode, - Capability.fan_speed, - ] - - # At least one of the optional capabilities must be supported - # to classify this entity as a fan. - # If they are not then return None and don't setup the platform. - if not any(capability in capabilities for capability in optional): - return None - - supported = [Capability.switch] - - supported.extend( - capability for capability in optional if capability in capabilities - ) - - return supported - - class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" + _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, device): + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + rooms, + { + Capability.SWITCH, + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + }, + ) self._attr_supported_features = self._determine_features() def _determine_features(self): flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - if self._device.get_capability(Capability.fan_speed): + if self.supports_capability(Capability.FAN_SPEED): flags |= FanEntityFeature.SET_SPEED - if self._device.get_capability(Capability.air_conditioner_fan_mode): + if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): flags |= FanEntityFeature.PRESET_MODE return flags async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - await self._async_set_percentage(percentage) - - async def _async_set_percentage(self, percentage: int | None) -> None: - if percentage is None: - await self._device.switch_on(set_status=True) - elif percentage == 0: - await self._device.switch_off(set_status=True) + if percentage == 0: + await self.execute_device_command(Capability.SWITCH, Command.OFF) else: value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - await self._device.set_fan_speed(value, set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + argument=value, + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - await self._device.set_fan_mode(preset_mode, set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=preset_mode, + ) async def async_turn_on( self, @@ -114,32 +104,30 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - if FanEntityFeature.SET_SPEED in self._attr_supported_features: - # If speed is set in features then turn the fan on with the speed. - await self._async_set_percentage(percentage) + if ( + FanEntityFeature.SET_SPEED in self._attr_supported_features + and percentage is not None + ): + await self.async_set_percentage(percentage) else: - # If speed is not valid then turn on the fan with the - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.OFF) @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) @property def percentage(self) -> int | None: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + return ranged_value_to_percentage( + SPEED_RANGE, + self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED), + ) @property def preset_mode(self) -> str | None: @@ -147,7 +135,9 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def preset_modes(self) -> list[str] | None: @@ -155,4 +145,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index eb7c9af246b..eee333f131f 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,13 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence -from typing import Any +from typing import Any, cast -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -18,104 +18,98 @@ from homeassistant.components.light import ( LightEntityFeature, brightness_supported, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add lights for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsLight(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "light") - ], - True, + SmartThingsLight(entry_data.client, entry_data.rooms, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any(capability in device.status[MAIN] for capability in CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ] - # Must be able to be turned on/off. - if Capability.switch not in capabilities: - return None - # Must have one of these - light_capabilities = [ - Capability.color_control, - Capability.color_temperature, - Capability.switch_level, - ] - if any(capability in capabilities for capability in light_capabilities): - return supported - return None - - -def convert_scale(value, value_scale, target_scale, round_digits=4): +def convert_scale( + value: float, value_scale: int, target_scale: int, round_digits: int = 4 +) -> float: """Convert a value to a different scale.""" return round(value * target_scale / value_scale, round_digits) -class SmartThingsLight(SmartThingsEntity, LightEntity): +class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Define a SmartThings Light.""" + _attr_name = None _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # lowest kelvin found supported across 20+ handlers. _attr_min_color_temp_kelvin = 2000 # 500 mireds # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, device): + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Initialize a SmartThingsLight.""" - super().__init__(device) - self._attr_supported_color_modes = self._determine_color_modes() - self._attr_supported_features = self._determine_features() - - def _determine_color_modes(self): - """Get features supported by the device.""" + super().__init__( + client, + device, + rooms, + { + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.SWITCH_LEVEL, + Capability.SWITCH, + }, + ) color_modes = set() - # Color Temperature - if Capability.color_temperature in self._device.capabilities: + if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) - # Color - if Capability.color_control in self._device.capabilities: + self._attr_color_mode = ColorMode.COLOR_TEMP + if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) - # Brightness - if not color_modes and Capability.switch_level in self._device.capabilities: + self._attr_color_mode = ColorMode.HS + if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) - - return color_modes - - def _determine_features(self) -> LightEntityFeature: - """Get features supported by the device.""" + if len(color_modes) == 1: + self._attr_color_mode = list(color_modes)[0] + self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) - # Transition - if Capability.switch_level in self._device.capabilities: + if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION + self._attr_supported_features = features - return features + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_extra_data()) is not None: + self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE] async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" @@ -136,11 +130,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0) ) else: - await self._device.switch_on(set_status=True) - - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" @@ -148,27 +141,39 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): if ATTR_TRANSITION in kwargs: await self.async_set_level(0, int(kwargs[ATTR_TRANSITION])) else: - await self._device.switch_off(set_status=True) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): self._attr_brightness = int( - convert_scale(self._device.status.level, 100, 255, 0) + convert_scale( + self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), + 100, + 255, + 0, + ) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._attr_color_temp_kelvin = self._device.status.color_temperature + self._attr_color_temp_kelvin = self.get_attribute_value( + Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE + ) # Color if ColorMode.HS in self._attr_supported_color_modes: self._attr_hs_color = ( - convert_scale(self._device.status.hue, 100, 360), - self._device.status.saturation, + convert_scale( + self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), ) async def async_set_color(self, hs_color): @@ -176,14 +181,22 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): hue = convert_scale(float(hs_color[0]), 360, 100) hue = max(min(hue, 100.0), 0.0) saturation = max(min(float(hs_color[1]), 100.0), 0.0) - await self._device.set_color(hue, saturation, set_status=True) + await self.execute_device_command( + Capability.COLOR_CONTROL, + Command.SET_COLOR, + argument={"hue": hue, "saturation": saturation}, + ) async def async_set_color_temp(self, value: int): """Set the color temperature of the device.""" kelvin = max(min(value, 30000), 1) - await self._device.set_color_temperature(kelvin, set_status=True) + await self.execute_device_command( + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + argument=kelvin, + ) - async def async_set_level(self, brightness: int, transition: int): + async def async_set_level(self, brightness: int, transition: int) -> None: """Set the brightness of the light over transition.""" level = int(convert_scale(brightness, 255, 100, 0)) # Due to rounding, set level to 1 (one) so we don't inadvertently @@ -191,21 +204,22 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): level = 1 if level == 0 and brightness > 0 else level level = max(min(level, 100), 0) duration = int(transition) - await self._device.set_level(level, duration, set_status=True) + await self.execute_device_command( + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + argument=[level, duration], + ) - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if len(self._attr_supported_color_modes) == 1: - # The light supports only a single color mode - return list(self._attr_supported_color_modes)[0] - - # The light supports hs + color temp, determine which one it is - if self._attr_hs_color and self._attr_hs_color[1]: - return ColorMode.HS - return ColorMode.COLOR_TEMP + def _update_handler(self, event: DeviceEvent) -> None: + """Handle device updates.""" + if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE): + self._attr_color_mode = { + Capability.COLOR_CONTROL: ColorMode.HS, + Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP, + }[cast(Capability, event.capability)] + super()._update_handler(event) @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index a0ae9e50443..76a643e417e 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -2,17 +2,16 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ST_STATE_LOCKED = "locked" @@ -28,48 +27,49 @@ ST_LOCK_ATTR_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add locks for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "lock") + SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) + for device in entry_data.devices.values() + if Capability.LOCK in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - if Capability.lock in capabilities: - return [Capability.lock] - return None - - class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" + _attr_name = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - await self._device.lock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.LOCK, + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - await self._device.unlock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.UNLOCK, + ) @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._device.status.lock == ST_STATE_LOCKED + return ( + self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attrs = {} - status = self._device.status.attributes[Attribute.lock] + status = self._internal_state[Capability.LOCK][Attribute.LOCK] if status.value: state_attrs["lock_state"] = status.value if isinstance(status.data, dict): diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index be313248eaf..7a25dc2ac13 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -1,10 +1,9 @@ { "domain": "smartthings", "name": "SmartThings", - "after_dependencies": ["cloud"], - "codeowners": [], + "codeowners": ["@joostlek"], "config_flow": true, - "dependencies": ["webhook"], + "dependencies": ["application_credentials"], "dhcp": [ { "hostname": "st*", @@ -29,6 +28,6 @@ ], "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", - "loggers": ["httpsig", "pysmartapp", "pysmartthings"], - "requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"] + "loggers": ["pysmartthings"], + "requirements": ["pysmartthings==2.4.1"] } diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index 9756cef9f04..2b387859f22 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -2,39 +2,42 @@ from typing import Any -from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pysmartthings import Scene as STScene, SmartThings -from .const import DATA_BROKERS, DOMAIN +from homeassistant.components.scene import Scene +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SmartThingsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - async_add_entities(SmartThingsScene(scene) for scene in broker.scenes.values()) + """Add lights for a config entry.""" + client = entry.runtime_data.client + scenes = entry.runtime_data.scenes + async_add_entities(SmartThingsScene(scene, client) for scene in scenes.values()) class SmartThingsScene(Scene): """Define a SmartThings scene.""" - def __init__(self, scene): + def __init__(self, scene: STScene, client: SmartThings) -> None: """Init the scene class.""" + self.client = client self._scene = scene self._attr_name = scene.name self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" - await self._scene.execute() + await self.client.execute_scene(self._scene.scene_id) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Get attributes about the state.""" return { "icon": self._scene.icon, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 8bd0421d2bc..ff6e7f252b0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,25 +2,26 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import NamedTuple +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any -from pysmartthings import Attribute, Capability -from pysmartthings.device import DeviceEntity +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfArea, - UnitOfElectricPotential, UnitOfEnergy, UnitOfMass, UnitOfPower, @@ -28,711 +29,1018 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity - -class Map(NamedTuple): - """Tuple for mapping Smartthings capabilities to Home Assistant sensors.""" - - attribute: str - name: str - default_unit: str | None - device_class: SensorDeviceClass | None - state_class: SensorStateClass | None - entity_category: EntityCategory | None - - -CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { - Capability.activity_lighting_mode: [ - Map( - Attribute.lighting_mode, - "Activity Lighting Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.air_conditioner_mode: [ - Map( - Attribute.air_conditioner_mode, - "Air Conditioner Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.air_quality_sensor: [ - Map( - Attribute.air_quality, - "Air Quality", - "CAQI", - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None, None)], - Capability.audio_volume: [ - Map(Attribute.volume, "Volume", PERCENTAGE, None, None, None) - ], - Capability.battery: [ - Map( - Attribute.battery, - "Battery", - PERCENTAGE, - SensorDeviceClass.BATTERY, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.body_mass_index_measurement: [ - Map( - Attribute.bmi_measurement, - "Body Mass Index", - f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.body_weight_measurement: [ - Map( - Attribute.body_weight_measurement, - "Body Weight", - UnitOfMass.KILOGRAMS, - SensorDeviceClass.WEIGHT, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.carbon_dioxide_measurement: [ - Map( - Attribute.carbon_dioxide, - "Carbon Dioxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.carbon_monoxide_detector: [ - Map( - Attribute.carbon_monoxide, - "Carbon Monoxide Detector", - None, - None, - None, - None, - ) - ], - Capability.carbon_monoxide_measurement: [ - Map( - Attribute.carbon_monoxide_level, - "Carbon Monoxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.dishwasher_operating_state: [ - Map( - Attribute.machine_state, "Dishwasher Machine State", None, None, None, None - ), - Map( - Attribute.dishwasher_job_state, - "Dishwasher Job State", - None, - None, - None, - None, - ), - Map( - Attribute.completion_time, - "Dishwasher Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], - Capability.dryer_mode: [ - Map( - Attribute.dryer_mode, - "Dryer Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.dryer_operating_state: [ - Map(Attribute.machine_state, "Dryer Machine State", None, None, None, None), - Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None, None), - Map( - Attribute.completion_time, - "Dryer Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], - Capability.dust_sensor: [ - Map( - Attribute.fine_dust_level, - "Fine Dust Level", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ), - Map( - Attribute.dust_level, - "Dust Level", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ), - ], - Capability.energy_meter: [ - Map( - Attribute.energy, - "Energy Meter", - UnitOfEnergy.KILO_WATT_HOUR, - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - None, - ) - ], - Capability.equivalent_carbon_dioxide_measurement: [ - Map( - Attribute.equivalent_carbon_dioxide_measurement, - "Equivalent Carbon Dioxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.formaldehyde_measurement: [ - Map( - Attribute.formaldehyde_level, - "Formaldehyde Measurement", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.gas_meter: [ - Map( - Attribute.gas_meter, - "Gas Meter", - UnitOfEnergy.KILO_WATT_HOUR, - SensorDeviceClass.ENERGY, - SensorStateClass.MEASUREMENT, - None, - ), - Map( - Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None, None - ), - Map( - Attribute.gas_meter_time, - "Gas Meter Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - Map( - Attribute.gas_meter_volume, - "Gas Meter Volume", - UnitOfVolume.CUBIC_METERS, - SensorDeviceClass.GAS, - SensorStateClass.MEASUREMENT, - None, - ), - ], - Capability.illuminance_measurement: [ - Map( - Attribute.illuminance, - "Illuminance", - LIGHT_LUX, - SensorDeviceClass.ILLUMINANCE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.infrared_level: [ - Map( - Attribute.infrared_level, - "Infrared Level", - PERCENTAGE, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.media_input_source: [ - Map(Attribute.input_source, "Media Input Source", None, None, None, None) - ], - Capability.media_playback_repeat: [ - Map( - Attribute.playback_repeat_mode, - "Media Playback Repeat", - None, - None, - None, - None, - ) - ], - Capability.media_playback_shuffle: [ - Map( - Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None, None - ) - ], - Capability.media_playback: [ - Map(Attribute.playback_status, "Media Playback Status", None, None, None, None) - ], - Capability.odor_sensor: [ - Map(Attribute.odor_level, "Odor Sensor", None, None, None, None) - ], - Capability.oven_mode: [ - Map( - Attribute.oven_mode, - "Oven Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.oven_operating_state: [ - Map(Attribute.machine_state, "Oven Machine State", None, None, None, None), - Map(Attribute.oven_job_state, "Oven Job State", None, None, None, None), - Map(Attribute.completion_time, "Oven Completion Time", None, None, None, None), - ], - Capability.oven_setpoint: [ - Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None, None) - ], - Capability.power_consumption_report: [], - Capability.power_meter: [ - Map( - Attribute.power, - "Power Meter", - UnitOfPower.WATT, - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.power_source: [ - Map( - Attribute.power_source, - "Power Source", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.refrigeration_setpoint: [ - Map( - Attribute.refrigeration_setpoint, - "Refrigeration Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - None, - ) - ], - Capability.relative_humidity_measurement: [ - Map( - Attribute.humidity, - "Relative Humidity Measurement", - PERCENTAGE, - SensorDeviceClass.HUMIDITY, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.robot_cleaner_cleaning_mode: [ - Map( - Attribute.robot_cleaner_cleaning_mode, - "Robot Cleaner Cleaning Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.robot_cleaner_movement: [ - Map( - Attribute.robot_cleaner_movement, - "Robot Cleaner Movement", - None, - None, - None, - None, - ) - ], - Capability.robot_cleaner_turbo_mode: [ - Map( - Attribute.robot_cleaner_turbo_mode, - "Robot Cleaner Turbo Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.signal_strength: [ - Map( - Attribute.lqi, - "LQI Signal Strength", - None, - None, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ), - Map( - Attribute.rssi, - "RSSI Signal Strength", - None, - SensorDeviceClass.SIGNAL_STRENGTH, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ), - ], - Capability.smoke_detector: [ - Map(Attribute.smoke, "Smoke Detector", None, None, None, None) - ], - Capability.temperature_measurement: [ - Map( - Attribute.temperature, - "Temperature Measurement", - None, - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.thermostat_cooling_setpoint: [ - Map( - Attribute.cooling_setpoint, - "Thermostat Cooling Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - None, - ) - ], - Capability.thermostat_fan_mode: [ - Map( - Attribute.thermostat_fan_mode, - "Thermostat Fan Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_heating_setpoint: [ - Map( - Attribute.heating_setpoint, - "Thermostat Heating Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_mode: [ - Map( - Attribute.thermostat_mode, - "Thermostat Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_operating_state: [ - Map( - Attribute.thermostat_operating_state, - "Thermostat Operating State", - None, - None, - None, - None, - ) - ], - Capability.thermostat_setpoint: [ - Map( - Attribute.thermostat_setpoint, - "Thermostat Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.three_axis: [], - Capability.tv_channel: [ - Map(Attribute.tv_channel, "Tv Channel", None, None, None, None), - Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None, None), - ], - Capability.tvoc_measurement: [ - Map( - Attribute.tvoc_level, - "Tvoc Measurement", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.ultraviolet_index: [ - Map( - Attribute.ultraviolet_index, - "Ultraviolet Index", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.voltage_measurement: [ - Map( - Attribute.voltage, - "Voltage Measurement", - UnitOfElectricPotential.VOLT, - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.washer_mode: [ - Map( - Attribute.washer_mode, - "Washer Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.washer_operating_state: [ - Map(Attribute.machine_state, "Washer Machine State", None, None, None, None), - Map(Attribute.washer_job_state, "Washer Job State", None, None, None, None), - Map( - Attribute.completion_time, - "Washer Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], +THERMOSTAT_CAPABILITIES = { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, } +JOB_STATE_MAP = { + "airWash": "air_wash", + "airwash": "air_wash", + "aIRinse": "ai_rinse", + "aISpin": "ai_spin", + "aIWash": "ai_wash", + "aIDrying": "ai_drying", + "internalCare": "internal_care", + "continuousDehumidifying": "continuous_dehumidifying", + "thawingFrozenInside": "thawing_frozen_inside", + "delayWash": "delay_wash", + "weightSensing": "weight_sensing", + "freezeProtection": "freeze_protection", + "preDrain": "pre_drain", + "preWash": "pre_wash", + "wrinklePrevent": "wrinkle_prevent", + "unknown": None, +} + +OVEN_JOB_STATE_MAP = { + "scheduledStart": "scheduled_start", + "fastPreheat": "fast_preheat", + "scheduledEnd": "scheduled_end", + "stone_heating": "stone_heating", + "timeHoldPreheat": "time_hold_preheat", +} + +MEDIA_PLAYBACK_STATE_MAP = { + "fast forwarding": "fast_forwarding", +} + +ROBOT_CLEANER_TURBO_MODE_STATE_MAP = { + "extraSilence": "extra_silence", +} + +ROBOT_CLEANER_MOVEMENT_MAP = { + "powerOff": "off", +} + +OVEN_MODE = { + "Conventional": "conventional", + "Bake": "bake", + "BottomHeat": "bottom_heat", + "ConvectionBake": "convection_bake", + "ConvectionRoast": "convection_roast", + "Broil": "broil", + "ConvectionBroil": "convection_broil", + "SteamCook": "steam_cook", + "SteamBake": "steam_bake", + "SteamRoast": "steam_roast", + "SteamBottomHeatplusConvection": "steam_bottom_heat_plus_convection", + "Microwave": "microwave", + "MWplusGrill": "microwave_plus_grill", + "MWplusConvection": "microwave_plus_convection", + "MWplusHotBlast": "microwave_plus_hot_blast", + "MWplusHotBlast2": "microwave_plus_hot_blast_2", + "SlimMiddle": "slim_middle", + "SlimStrong": "slim_strong", + "SlowCook": "slow_cook", + "Proof": "proof", + "Dehydrate": "dehydrate", + "Others": "others", + "StrongSteam": "strong_steam", + "Descale": "descale", + "Rinse": "rinse", +} + +WASHER_OPTIONS = ["pause", "run", "stop"] + + +def power_attributes(status: dict[str, Any]) -> dict[str, Any]: + """Return the power attributes.""" + state = {} + for attribute in ("start", "end"): + if (value := status.get(attribute)) is not None: + state[f"power_consumption_{attribute}"] = value + return state + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSensorEntityDescription(SensorEntityDescription): + """Describe a SmartThings sensor entity.""" + + value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value + extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None + unique_id_separator: str = "." + capability_ignore_list: list[set[Capability]] | None = None + options_attribute: Attribute | None = None + except_if_state_none: bool = False + + +CAPABILITY_TO_SENSORS: dict[ + Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] +] = { + # Haven't seen at devices yet + Capability.ACTIVITY_LIGHTING_MODE: { + Attribute.LIGHTING_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.LIGHTING_MODE, + translation_key="lighting_mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.AIR_CONDITIONER_MODE: { + Attribute.AIR_CONDITIONER_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.AIR_CONDITIONER_MODE, + translation_key="air_conditioner_mode", + entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[ + { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, + } + ], + ) + ] + }, + Capability.AIR_QUALITY_SENSOR: { + Attribute.AIR_QUALITY: [ + SmartThingsSensorEntityDescription( + key=Attribute.AIR_QUALITY, + translation_key="air_quality", + native_unit_of_measurement="CAQI", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.ALARM: { + Attribute.ALARM: [ + SmartThingsSensorEntityDescription( + key=Attribute.ALARM, + translation_key="alarm", + options=["both", "strobe", "siren", "off"], + device_class=SensorDeviceClass.ENUM, + ) + ] + }, + Capability.AUDIO_VOLUME: { + Attribute.VOLUME: [ + SmartThingsSensorEntityDescription( + key=Attribute.VOLUME, + translation_key="audio_volume", + native_unit_of_measurement=PERCENTAGE, + ) + ] + }, + Capability.BATTERY: { + Attribute.BATTERY: [ + SmartThingsSensorEntityDescription( + key=Attribute.BATTERY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + # Haven't seen at devices yet + Capability.BODY_MASS_INDEX_MEASUREMENT: { + Attribute.BMI_MEASUREMENT: [ + SmartThingsSensorEntityDescription( + key=Attribute.BMI_MEASUREMENT, + translation_key="body_mass_index", + native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.BODY_WEIGHT_MEASUREMENT: { + Attribute.BODY_WEIGHT_MEASUREMENT: [ + SmartThingsSensorEntityDescription( + key=Attribute.BODY_WEIGHT_MEASUREMENT, + translation_key="body_weight", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.CARBON_DIOXIDE_MEASUREMENT: { + Attribute.CARBON_DIOXIDE: [ + SmartThingsSensorEntityDescription( + key=Attribute.CARBON_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.CARBON_MONOXIDE_DETECTOR: { + Attribute.CARBON_MONOXIDE: [ + SmartThingsSensorEntityDescription( + key=Attribute.CARBON_MONOXIDE, + translation_key="carbon_monoxide_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, + ) + ] + }, + # Haven't seen at devices yet + Capability.CARBON_MONOXIDE_MEASUREMENT: { + Attribute.CARBON_MONOXIDE_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.CARBON_MONOXIDE_LEVEL, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.DISHWASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.MACHINE_STATE, + translation_key="dishwasher_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, + ) + ], + Attribute.DISHWASHER_JOB_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.DISHWASHER_JOB_STATE, + translation_key="dishwasher_job_state", + options=[ + "air_wash", + "cooling", + "drying", + "finish", + "pre_drain", + "pre_wash", + "rinse", + "spin", + "wash", + "wrinkle_prevent", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), + ) + ], + Attribute.COMPLETION_TIME: [ + SmartThingsSensorEntityDescription( + key=Attribute.COMPLETION_TIME, + translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, + # part of the proposed spec, Haven't seen at devices yet + Capability.DRYER_MODE: { + Attribute.DRYER_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.DRYER_MODE, + translation_key="dryer_mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.DRYER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.MACHINE_STATE, + translation_key="dryer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, + ) + ], + Attribute.DRYER_JOB_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.DRYER_JOB_STATE, + translation_key="dryer_job_state", + options=[ + "cooling", + "delay_wash", + "drying", + "finished", + "none", + "refreshing", + "weight_sensing", + "wrinkle_prevent", + "dehumidifying", + "ai_drying", + "sanitizing", + "internal_care", + "freeze_protection", + "continuous_dehumidifying", + "thawing_frozen_inside", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), + ) + ], + Attribute.COMPLETION_TIME: [ + SmartThingsSensorEntityDescription( + key=Attribute.COMPLETION_TIME, + translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, + Capability.DUST_SENSOR: { + Attribute.DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.DUST_LEVEL, + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ) + ], + Attribute.FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.FINE_DUST_LEVEL, + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + Capability.ENERGY_METER: { + Attribute.ENERGY: [ + SmartThingsSensorEntityDescription( + key=Attribute.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + ] + }, + # Haven't seen at devices yet + Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ + SmartThingsSensorEntityDescription( + key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, + translation_key="equivalent_carbon_dioxide", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.FORMALDEHYDE_MEASUREMENT: { + Attribute.FORMALDEHYDE_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.FORMALDEHYDE_LEVEL, + translation_key="formaldehyde", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.GAS_METER: { + Attribute.GAS_METER: [ + SmartThingsSensorEntityDescription( + key=Attribute.GAS_METER, + translation_key="gas_meter", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.MEASUREMENT, + ) + ], + Attribute.GAS_METER_CALORIFIC: [ + SmartThingsSensorEntityDescription( + key=Attribute.GAS_METER_CALORIFIC, + translation_key="gas_meter_calorific", + ) + ], + Attribute.GAS_METER_TIME: [ + SmartThingsSensorEntityDescription( + key=Attribute.GAS_METER_TIME, + translation_key="gas_meter_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + Attribute.GAS_METER_VOLUME: [ + SmartThingsSensorEntityDescription( + key=Attribute.GAS_METER_VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + # Haven't seen at devices yet + Capability.ILLUMINANCE_MEASUREMENT: { + Attribute.ILLUMINANCE: [ + SmartThingsSensorEntityDescription( + key=Attribute.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.INFRARED_LEVEL: { + Attribute.INFRARED_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.INFRARED_LEVEL, + translation_key="infrared_level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.MEDIA_INPUT_SOURCE: { + Attribute.INPUT_SOURCE: [ + SmartThingsSensorEntityDescription( + key=Attribute.INPUT_SOURCE, + translation_key="media_input_source", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, + value_fn=lambda value: value.lower() if value else None, + ) + ] + }, + # part of the proposed spec, Haven't seen at devices yet + Capability.MEDIA_PLAYBACK_REPEAT: { + Attribute.PLAYBACK_REPEAT_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.PLAYBACK_REPEAT_MODE, + translation_key="media_playback_repeat", + ) + ] + }, + # part of the proposed spec, Haven't seen at devices yet + Capability.MEDIA_PLAYBACK_SHUFFLE: { + Attribute.PLAYBACK_SHUFFLE: [ + SmartThingsSensorEntityDescription( + key=Attribute.PLAYBACK_SHUFFLE, + translation_key="media_playback_shuffle", + ) + ] + }, + Capability.MEDIA_PLAYBACK: { + Attribute.PLAYBACK_STATUS: [ + SmartThingsSensorEntityDescription( + key=Attribute.PLAYBACK_STATUS, + translation_key="media_playback_status", + options=[ + "paused", + "playing", + "stopped", + "fast_forwarding", + "rewinding", + "buffering", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), + ) + ] + }, + Capability.ODOR_SENSOR: { + Attribute.ODOR_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.ODOR_LEVEL, + translation_key="odor_sensor", + ) + ] + }, + Capability.OVEN_MODE: { + Attribute.OVEN_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.OVEN_MODE, + translation_key="oven_mode", + entity_category=EntityCategory.DIAGNOSTIC, + options=list(OVEN_MODE.values()), + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_MODE.get(value, value), + ) + ] + }, + Capability.OVEN_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.MACHINE_STATE, + translation_key="oven_machine_state", + options=["ready", "running", "paused"], + device_class=SensorDeviceClass.ENUM, + ) + ], + Attribute.OVEN_JOB_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.OVEN_JOB_STATE, + translation_key="oven_job_state", + options=[ + "cleaning", + "cooking", + "cooling", + "draining", + "preheat", + "ready", + "rinsing", + "finished", + "scheduled_start", + "warming", + "defrosting", + "sensing", + "searing", + "fast_preheat", + "scheduled_end", + "stone_heating", + "time_hold_preheat", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_JOB_STATE_MAP.get(value, value), + ) + ], + Attribute.COMPLETION_TIME: [ + SmartThingsSensorEntityDescription( + key=Attribute.COMPLETION_TIME, + translation_key="completion_time", + ) + ], + }, + Capability.OVEN_SETPOINT: { + Attribute.OVEN_SETPOINT: [ + SmartThingsSensorEntityDescription( + key=Attribute.OVEN_SETPOINT, + translation_key="oven_setpoint", + ) + ] + }, + Capability.POWER_CONSUMPTION_REPORT: { + Attribute.POWER_CONSUMPTION: [ + SmartThingsSensorEntityDescription( + key="energy_meter", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: value["energy"] / 1000, + suggested_display_precision=2, + except_if_state_none=True, + ), + SmartThingsSensorEntityDescription( + key="power_meter", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda value: value["power"], + extra_state_attributes_fn=power_attributes, + suggested_display_precision=2, + except_if_state_none=True, + ), + SmartThingsSensorEntityDescription( + key="deltaEnergy_meter", + translation_key="energy_difference", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: value["deltaEnergy"] / 1000, + suggested_display_precision=2, + except_if_state_none=True, + ), + SmartThingsSensorEntityDescription( + key="powerEnergy_meter", + translation_key="power_energy", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: value["powerEnergy"] / 1000, + suggested_display_precision=2, + except_if_state_none=True, + ), + SmartThingsSensorEntityDescription( + key="energySaved_meter", + translation_key="energy_saved", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: value["energySaved"] / 1000, + suggested_display_precision=2, + except_if_state_none=True, + ), + ] + }, + Capability.POWER_METER: { + Attribute.POWER: [ + SmartThingsSensorEntityDescription( + key=Attribute.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.POWER_SOURCE: { + Attribute.POWER_SOURCE: [ + SmartThingsSensorEntityDescription( + key=Attribute.POWER_SOURCE, + translation_key="power_source", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + # part of the proposed spec + Capability.REFRIGERATION_SETPOINT: { + Attribute.REFRIGERATION_SETPOINT: [ + SmartThingsSensorEntityDescription( + key=Attribute.REFRIGERATION_SETPOINT, + translation_key="refrigeration_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + ) + ] + }, + Capability.RELATIVE_HUMIDITY_MEASUREMENT: { + Attribute.HUMIDITY: [ + SmartThingsSensorEntityDescription( + key=Attribute.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.ROBOT_CLEANER_CLEANING_MODE: { + Attribute.ROBOT_CLEANER_CLEANING_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.ROBOT_CLEANER_CLEANING_MODE, + translation_key="robot_cleaner_cleaning_mode", + options=["auto", "part", "repeat", "manual", "stop", "map"], + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + }, + Capability.ROBOT_CLEANER_MOVEMENT: { + Attribute.ROBOT_CLEANER_MOVEMENT: [ + SmartThingsSensorEntityDescription( + key=Attribute.ROBOT_CLEANER_MOVEMENT, + translation_key="robot_cleaner_movement", + options=[ + "homing", + "idle", + "charging", + "alarm", + "off", + "reserve", + "point", + "after", + "cleaning", + "pause", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_MOVEMENT_MAP.get(value, value), + ) + ] + }, + Capability.ROBOT_CLEANER_TURBO_MODE: { + Attribute.ROBOT_CLEANER_TURBO_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.ROBOT_CLEANER_TURBO_MODE, + translation_key="robot_cleaner_turbo_mode", + options=["on", "off", "silence", "extra_silence"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_TURBO_MODE_STATE_MAP.get( + value, value + ), + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + # Haven't seen at devices yet + Capability.SIGNAL_STRENGTH: { + Attribute.LQI: [ + SmartThingsSensorEntityDescription( + key=Attribute.LQI, + translation_key="link_quality", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + Attribute.RSSI: [ + SmartThingsSensorEntityDescription( + key=Attribute.RSSI, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + }, + # Haven't seen at devices yet + Capability.SMOKE_DETECTOR: { + Attribute.SMOKE: [ + SmartThingsSensorEntityDescription( + key=Attribute.SMOKE, + translation_key="smoke_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, + ) + ] + }, + Capability.TEMPERATURE_MEASUREMENT: { + Attribute.TEMPERATURE: [ + SmartThingsSensorEntityDescription( + key=Attribute.TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.THERMOSTAT_COOLING_SETPOINT: { + Attribute.COOLING_SETPOINT: [ + SmartThingsSensorEntityDescription( + key=Attribute.COOLING_SETPOINT, + translation_key="thermostat_cooling_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + capability_ignore_list=[ + { + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.AIR_CONDITIONER_MODE, + }, + THERMOSTAT_CAPABILITIES, + ], + ) + ] + }, + # Haven't seen at devices yet + Capability.THERMOSTAT_FAN_MODE: { + Attribute.THERMOSTAT_FAN_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.THERMOSTAT_FAN_MODE, + translation_key="thermostat_fan_mode", + entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], + ) + ] + }, + # Haven't seen at devices yet + Capability.THERMOSTAT_HEATING_SETPOINT: { + Attribute.HEATING_SETPOINT: [ + SmartThingsSensorEntityDescription( + key=Attribute.HEATING_SETPOINT, + translation_key="thermostat_heating_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], + ) + ] + }, + # Haven't seen at devices yet + Capability.THERMOSTAT_MODE: { + Attribute.THERMOSTAT_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.THERMOSTAT_MODE, + translation_key="thermostat_mode", + entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], + ) + ] + }, + # Haven't seen at devices yet + Capability.THERMOSTAT_OPERATING_STATE: { + Attribute.THERMOSTAT_OPERATING_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.THERMOSTAT_OPERATING_STATE, + translation_key="thermostat_operating_state", + capability_ignore_list=[THERMOSTAT_CAPABILITIES], + ) + ] + }, + # deprecated capability + Capability.THERMOSTAT_SETPOINT: { + Attribute.THERMOSTAT_SETPOINT: [ + SmartThingsSensorEntityDescription( + key=Attribute.THERMOSTAT_SETPOINT, + translation_key="thermostat_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.THREE_AXIS: { + Attribute.THREE_AXIS: [ + SmartThingsSensorEntityDescription( + key="X Coordinate", + translation_key="x_coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[0], + ), + SmartThingsSensorEntityDescription( + key="Y Coordinate", + translation_key="y_coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[1], + ), + SmartThingsSensorEntityDescription( + key="Z Coordinate", + translation_key="z_coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[2], + ), + ] + }, + Capability.TV_CHANNEL: { + Attribute.TV_CHANNEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.TV_CHANNEL, + translation_key="tv_channel", + ) + ], + Attribute.TV_CHANNEL_NAME: [ + SmartThingsSensorEntityDescription( + key=Attribute.TV_CHANNEL_NAME, + translation_key="tv_channel_name", + ) + ], + }, + # Haven't seen at devices yet + Capability.TVOC_MEASUREMENT: { + Attribute.TVOC_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.TVOC_LEVEL, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # Haven't seen at devices yet + Capability.ULTRAVIOLET_INDEX: { + Attribute.ULTRAVIOLET_INDEX: [ + SmartThingsSensorEntityDescription( + key=Attribute.ULTRAVIOLET_INDEX, + translation_key="uv_index", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.VOLTAGE_MEASUREMENT: { + Attribute.VOLTAGE: [ + SmartThingsSensorEntityDescription( + key=Attribute.VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + # part of the proposed spec + Capability.WASHER_MODE: { + Attribute.WASHER_MODE: [ + SmartThingsSensorEntityDescription( + key=Attribute.WASHER_MODE, + translation_key="washer_mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.WASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.MACHINE_STATE, + translation_key="washer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, + ) + ], + Attribute.WASHER_JOB_STATE: [ + SmartThingsSensorEntityDescription( + key=Attribute.WASHER_JOB_STATE, + translation_key="washer_job_state", + options=[ + "air_wash", + "ai_rinse", + "ai_spin", + "ai_wash", + "cooling", + "delay_wash", + "drying", + "finish", + "none", + "pre_wash", + "rinse", + "spin", + "wash", + "weight_sensing", + "wrinkle_prevent", + "freeze_protection", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), + ) + ], + Attribute.COMPLETION_TIME: [ + SmartThingsSensorEntityDescription( + key=Attribute.COMPLETION_TIME, + translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, +} + + UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, + "mG": None, } -THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"] -POWER_CONSUMPTION_REPORT_NAMES = [ - "energy", - "power", - "deltaEnergy", - "powerEnergy", - "energySaved", -] - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[SensorEntity] = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "sensor"): - if capability == Capability.three_axis: - entities.extend( - [ - SmartThingsThreeAxisSensor(device, index) - for index in range(len(THREE_AXIS_NAMES)) - ] - ) - elif capability == Capability.power_consumption_report: - entities.extend( - [ - SmartThingsPowerConsumptionSensor(device, report_name) - for report_name in POWER_CONSUMPTION_REPORT_NAMES - ] - ) - else: - maps = CAPABILITY_TO_SENSORS[capability] - entities.extend( - [ - SmartThingsSensor( - device, - m.attribute, - m.name, - m.default_unit, - m.device_class, - m.state_class, - m.entity_category, - ) - for m in maps - ] - ) - - if broker.any_assigned(device.device_id, "switch"): - for capability in (Capability.energy_meter, Capability.power_meter): - maps = CAPABILITY_TO_SENSORS[capability] - entities.extend( - [ - SmartThingsSensor( - device, - m.attribute, - m.name, - m.default_unit, - m.device_class, - m.state_class, - m.entity_category, - ) - for m in maps - ] - ) - - async_add_entities(entities) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_SENSORS if capability in capabilities - ] + entry_data = entry.runtime_data + async_add_entities( + SmartThingsSensor( + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, + ) + for device in entry_data.devices.values() + for capability, attributes in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, descriptions in attributes.items() + for description in descriptions + if ( + not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.except_if_state_none + or device.status[MAIN][capability][attribute].value is not None + ) + ) class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" + entity_description: SmartThingsSensorEntityDescription + def __init__( self, - device: DeviceEntity, - attribute: str, - name: str, - default_unit: str | None, - device_class: SensorDeviceClass | None, - state_class: str | None, - entity_category: EntityCategory | None, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSensorEntityDescription, + rooms: dict[str, str], + capability: Capability, + attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, rooms, {capability}) + self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute - self._attr_name = f"{device.label} {name}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = device_class - self._default_unit = default_unit - self._attr_state_class = state_class - self._attr_entity_category = entity_category + self.capability = capability + self.entity_description = entity_description @property - def native_value(self): + def native_value(self) -> str | float | datetime | int | None: """Return the state of the sensor.""" - value = self._device.status.attributes[self._attribute].value - - if self.device_class != SensorDeviceClass.TIMESTAMP: - return value - - return dt_util.parse_datetime(value) + res = self.get_attribute_value(self.capability, self._attribute) + return self.entity_description.value_fn(res) @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._device.status.attributes[self._attribute].unit - return UNITS.get(unit, unit) if unit else self._default_unit - - -class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): - """Define a SmartThings Three Axis Sensor.""" - - def __init__(self, device, index): - """Init the class.""" - super().__init__(device) - self._index = index - self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" - self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" + unit = self._internal_state[self.capability][self._attribute].unit + return ( + UNITS.get(unit, unit) + if unit + else self.entity_description.native_unit_of_measurement + ) @property - def native_value(self): - """Return the state of the sensor.""" - three_axis = self._device.status.attributes[Attribute.three_axis].value - try: - return three_axis[self._index] - except (TypeError, IndexError): - return None - - -class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): - """Define a SmartThings Sensor.""" - - def __init__( - self, - device: DeviceEntity, - report_name: str, - ) -> None: - """Init the class.""" - super().__init__(device) - self.report_name = report_name - self._attr_name = f"{device.label} {report_name}" - self._attr_unique_id = f"{device.device_id}.{report_name}_meter" - if self.report_name == "power": - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.POWER - self._attr_native_unit_of_measurement = UnitOfPower.WATT - else: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - - @property - def native_value(self): - """Return the state of the sensor.""" - value = self._device.status.attributes[Attribute.power_consumption].value - if value is None or value.get(self.report_name) is None: - return None - if self.report_name == "power": - return value[self.report_name] - return value[self.report_name] / 1000 - - @property - def extra_state_attributes(self): - """Return specific state attributes.""" - if self.report_name == "power": - attributes = [ - "power_consumption_start", - "power_consumption_end", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self.get_attribute_value(self.capability, self._attribute) + ) return None + + @property + def options(self) -> list[str] | None: + """Return the options for this sensor.""" + if self.entity_description.options_attribute: + options = self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + return [option.lower() for option in options] + return super().options diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py deleted file mode 100644 index 6b0da00b132..00000000000 --- a/homeassistant/components/smartthings/smartapp.py +++ /dev/null @@ -1,511 +0,0 @@ -"""SmartApp functionality to receive cloud-push notifications.""" - -import asyncio -import functools -import logging -import secrets -from typing import Any -from urllib.parse import urlparse -from uuid import uuid4 - -from aiohttp import web -from pysmartapp import Dispatcher, SmartAppManager -from pysmartapp.const import SETTINGS_APP_ID -from pysmartthings import ( - APP_TYPE_WEBHOOK, - CAPABILITIES, - CLASSIFICATION_AUTOMATION, - App, - AppEntity, - AppOAuth, - AppSettings, - InstalledAppStatus, - SmartThings, - SourceType, - Subscription, - SubscriptionEntity, -) - -from homeassistant.components import cloud, webhook -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.storage import Store - -from .const import ( - APP_NAME_PREFIX, - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - IGNORED_CAPABILITIES, - SETTINGS_INSTANCE_ID, - SIGNAL_SMARTAPP_PREFIX, - STORAGE_KEY, - STORAGE_VERSION, - SUBSCRIPTION_WARNING_LIMIT, -) - -_LOGGER = logging.getLogger(__name__) - - -def format_unique_id(app_id: str, location_id: str) -> str: - """Format the unique id for a config entry.""" - return f"{app_id}_{location_id}" - - -async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None: - """Find an existing SmartApp for this installation of hass.""" - apps = await api.apps() - for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]: - # Load settings to compare instance id - settings = await app.settings() - if ( - settings.settings.get(SETTINGS_INSTANCE_ID) - == hass.data[DOMAIN][CONF_INSTANCE_ID] - ): - return app - return None - - -async def validate_installed_app(api, installed_app_id: str): - """Ensure the specified installed SmartApp is valid and functioning. - - Query the API for the installed SmartApp and validate that it is tied to - the specified app_id and is in an authorized state. - """ - installed_app = await api.installed_app(installed_app_id) - if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED: - raise RuntimeWarning( - f"Installed SmartApp instance '{installed_app.display_name}' " - f"({installed_app.installed_app_id}) is not AUTHORIZED " - f"but instead {installed_app.installed_app_status}" - ) - return installed_app - - -def validate_webhook_requirements(hass: HomeAssistant) -> bool: - """Ensure Home Assistant is setup properly to receive webhooks.""" - if cloud.async_active_subscription(hass): - return True - if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None: - return True - return get_webhook_url(hass).lower().startswith("https://") - - -def get_webhook_url(hass: HomeAssistant) -> str: - """Get the URL of the webhook. - - Return the cloudhook if available, otherwise local webhook. - """ - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloud.async_active_subscription(hass) and cloudhook_url is not None: - return cloudhook_url - return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - - -def _get_app_template(hass: HomeAssistant): - try: - endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}" - except NoURLAvailableError: - endpoint = "" - - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url is not None: - endpoint = "via Nabu Casa" - description = f"{hass.config.location_name} {endpoint}" - - return { - "app_name": APP_NAME_PREFIX + str(uuid4()), - "display_name": "Home Assistant", - "description": description, - "webhook_target_url": get_webhook_url(hass), - "app_type": APP_TYPE_WEBHOOK, - "single_instance": True, - "classifications": [CLASSIFICATION_AUTOMATION], - } - - -async def create_app(hass: HomeAssistant, api): - """Create a SmartApp for this instance of hass.""" - # Create app from template attributes - template = _get_app_template(hass) - app = App() - for key, value in template.items(): - setattr(app, key, value) - app, client = await api.create_app(app) - _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id) - - # Set unique hass id in settings - settings = AppSettings(app.app_id) - settings.settings[SETTINGS_APP_ID] = app.app_id - settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID] - await api.update_app_settings(settings) - _LOGGER.debug( - "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id - ) - - # Set oauth scopes - oauth = AppOAuth(app.app_id) - oauth.client_name = APP_OAUTH_CLIENT_NAME - oauth.scope.extend(APP_OAUTH_SCOPES) - await api.update_app_oauth(oauth) - _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id) - return app, client - - -async def update_app(hass: HomeAssistant, app): - """Ensure the SmartApp is up-to-date and update if necessary.""" - template = _get_app_template(hass) - template.pop("app_name") # don't update this - update_required = False - for key, value in template.items(): - if getattr(app, key) != value: - update_required = True - setattr(app, key, value) - if update_required: - await app.save() - _LOGGER.debug( - "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id - ) - - -def setup_smartapp(hass, app): - """Configure an individual SmartApp in hass. - - Register the SmartApp with the SmartAppManager so that hass will service - lifecycle events (install, event, etc...). A unique SmartApp is created - for each SmartThings account that is configured in hass. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - if smartapp := manager.smartapps.get(app.app_id): - # already setup - return smartapp - smartapp = manager.register(app.app_id, app.webhook_public_key) - smartapp.name = app.display_name - smartapp.description = app.description - smartapp.permissions.extend(APP_OAUTH_SCOPES) - return smartapp - - -async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): - """Configure the SmartApp webhook in hass. - - SmartApps are an extension point within the SmartThings ecosystem and - is used to receive push updates (i.e. device updates) from the cloud. - """ - if hass.data.get(DOMAIN): - # already setup - if not fresh_install: - return - - # We're doing a fresh install, clean up - await unload_smartapp_endpoint(hass) - - # Get/create config to store a unique id for this hass instance. - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - - if fresh_install or not (config := await store.async_load()): - # Create config - config = { - CONF_INSTANCE_ID: str(uuid4()), - CONF_WEBHOOK_ID: secrets.token_hex(), - CONF_CLOUDHOOK_URL: None, - } - await store.async_save(config) - - # Register webhook - webhook.async_register( - hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook - ) - - # Create webhook if eligible - cloudhook_url = config.get(CONF_CLOUDHOOK_URL) - if ( - cloudhook_url is None - and cloud.async_active_subscription(hass) - and not hass.config_entries.async_entries(DOMAIN) - ): - cloudhook_url = await cloud.async_create_cloudhook( - hass, config[CONF_WEBHOOK_ID] - ) - config[CONF_CLOUDHOOK_URL] = cloudhook_url - await store.async_save(config) - _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) - - # SmartAppManager uses a dispatcher to invoke callbacks when push events - # occur. Use hass' implementation instead of the built-in one. - dispatcher = Dispatcher( - signal_prefix=SIGNAL_SMARTAPP_PREFIX, - connect=functools.partial(async_dispatcher_connect, hass), - send=functools.partial(async_dispatcher_send, hass), - ) - # Path is used in digital signature validation - path = ( - urlparse(cloudhook_url).path - if cloudhook_url - else webhook.async_generate_path(config[CONF_WEBHOOK_ID]) - ) - manager = SmartAppManager(path, dispatcher=dispatcher) - manager.connect_install(functools.partial(smartapp_install, hass)) - manager.connect_update(functools.partial(smartapp_update, hass)) - manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) - - hass.data[DOMAIN] = { - DATA_MANAGER: manager, - CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], - DATA_BROKERS: {}, - CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], - # Will not be present if not enabled - CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), - } - _LOGGER.debug( - "Setup endpoint for %s", - cloudhook_url - if cloudhook_url - else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]), - ) - - -async def unload_smartapp_endpoint(hass: HomeAssistant): - """Tear down the component configuration.""" - if DOMAIN not in hass.data: - return - # Remove the cloudhook if it was created - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url and cloud.async_is_logged_in(hass): - await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Remove cloudhook from storage - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save( - { - CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], - CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], - CONF_CLOUDHOOK_URL: None, - } - ) - _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) - # Remove the webhook - webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Disconnect all brokers - for broker in hass.data[DOMAIN][DATA_BROKERS].values(): - broker.disconnect() - # Remove all handlers from manager - hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() - # Remove the component data - hass.data.pop(DOMAIN) - - -async def smartapp_sync_subscriptions( - hass: HomeAssistant, - auth_token: str, - location_id: str, - installed_app_id: str, - devices, -): - """Synchronize subscriptions of an installed up.""" - api = SmartThings(async_get_clientsession(hass), auth_token) - tasks = [] - - async def create_subscription(target: str): - sub = Subscription() - sub.installed_app_id = installed_app_id - sub.location_id = location_id - sub.source_type = SourceType.CAPABILITY - sub.capability = target - try: - await api.create_subscription(sub) - _LOGGER.debug( - "Created subscription for '%s' under app '%s'", target, installed_app_id - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to create subscription for '%s' under app '%s': %s", - target, - installed_app_id, - error, - ) - - async def delete_subscription(sub: SubscriptionEntity): - try: - await api.delete_subscription(installed_app_id, sub.subscription_id) - _LOGGER.debug( - ( - "Removed subscription for '%s' under app '%s' because it was no" - " longer needed" - ), - sub.capability, - installed_app_id, - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to remove subscription for '%s' under app '%s': %s", - sub.capability, - installed_app_id, - error, - ) - - # Build set of capabilities and prune unsupported ones - capabilities = set() - for device in devices: - capabilities.update(device.capabilities) - # Remove items not defined in the library - capabilities.intersection_update(CAPABILITIES) - # Remove unused capabilities - capabilities.difference_update(IGNORED_CAPABILITIES) - capability_count = len(capabilities) - if capability_count > SUBSCRIPTION_WARNING_LIMIT: - _LOGGER.warning( - ( - "Some device attributes may not receive push updates and there may be" - " subscription creation failures under app '%s' because %s" - " subscriptions are required but there is a limit of %s per app" - ), - installed_app_id, - capability_count, - SUBSCRIPTION_WARNING_LIMIT, - ) - _LOGGER.debug( - "Synchronizing subscriptions for %s capabilities under app '%s': %s", - capability_count, - installed_app_id, - capabilities, - ) - - # Get current subscriptions and find differences - subscriptions = await api.subscriptions(installed_app_id) - for subscription in subscriptions: - if subscription.capability in capabilities: - capabilities.remove(subscription.capability) - else: - # Delete the subscription - tasks.append(delete_subscription(subscription)) - - # Remaining capabilities need subscriptions created - tasks.extend([create_subscription(c) for c in capabilities]) - - if tasks: - await asyncio.gather(*tasks) - else: - _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) - - -async def _continue_flow( - hass: HomeAssistant, - app_id: str, - location_id: str, - installed_app_id: str, - refresh_token: str, -): - """Continue a config flow if one is in progress for the specific installed app.""" - unique_id = format_unique_id(app_id, location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - ), - None, - ) - if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) - - -async def smartapp_install(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp installation and continue the config flow.""" - await _continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Installed SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_update(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp update and either update the entry or continue the flow.""" - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} - ) - _LOGGER.debug( - "Updated config entry '%s' for SmartApp '%s' under parent app '%s'", - entry.entry_id, - req.installed_app_id, - app.app_id, - ) - - await _continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id - ) - - -async def smartapp_uninstall(hass: HomeAssistant, req, resp, app): - """Handle when a SmartApp is removed from a location by the user. - - Find and delete the config entry representing the integration. - """ - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - # Add as job not needed because the current coroutine was invoked - # from the dispatcher and is not being awaited. - await hass.config_entries.async_remove(entry.entry_id) - - _LOGGER.debug( - "Uninstalled SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request): - """Handle a smartapp lifecycle event callback from SmartThings. - - Requests from SmartThings are digitally signed and the SmartAppManager - validates the signature for authenticity. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - data = await request.json() - result = await manager.handle_request(data, request.headers) - return web.json_response(result) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index de94e5adfcd..9fd417284af 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -1,34 +1,393 @@ { "config": { "step": { - "user": { - "title": "Confirm Callback URL", - "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pat": { - "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", - "data": { - "access_token": "[%key:common::config_flow::data::access_token%]" - } - }, - "select_location": { - "title": "Select Location", - "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", - "data": { "location_id": "[%key:common::config_flow::data::location%]" } - }, - "authorize": { "title": "Authorize Home Assistant" } - }, - "abort": { - "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The SmartThings integration needs to re-authenticate your account" + } }, "error": { - "token_invalid_format": "The token must be in the UID/GUID format", - "token_unauthorized": "The token is invalid or no longer authorized.", - "token_forbidden": "The token does not have the required OAuth scopes.", - "app_setup_error": "Unable to set up the SmartApp. Please try again.", - "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "abort": { + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." + } + }, + "entity": { + "binary_sensor": { + "acceleration": { + "name": "Acceleration" + }, + "filter_status": { + "name": "Filter status" + }, + "valve": { + "name": "Valve" + } + }, + "sensor": { + "lighting_mode": { + "name": "Activity lighting mode" + }, + "air_conditioner_mode": { + "name": "Air conditioner mode" + }, + "air_quality": { + "name": "Air quality" + }, + "alarm": { + "name": "Alarm", + "state": { + "both": "Strobe and siren", + "strobe": "Strobe", + "siren": "Siren", + "off": "[%key:common::state::off%]" + } + }, + "audio_volume": { + "name": "Volume" + }, + "body_mass_index": { + "name": "Body mass index" + }, + "body_weight": { + "name": "Body weight" + }, + "carbon_monoxide_detector": { + "name": "Carbon monoxide detector", + "state": { + "detected": "Detected", + "clear": "Clear", + "tested": "Tested" + } + }, + "dishwasher_machine_state": { + "name": "Machine state", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "Running", + "stop": "Stopped" + } + }, + "dishwasher_job_state": { + "name": "Job state", + "state": { + "air_wash": "Air wash", + "cooling": "Cooling", + "drying": "Drying", + "finish": "Finish", + "pre_drain": "Pre-drain", + "pre_wash": "Pre-wash", + "rinse": "Rinse", + "spin": "Spin", + "wash": "Wash", + "wrinkle_prevent": "Wrinkle prevention" + } + }, + "completion_time": { + "name": "Completion time" + }, + "dryer_mode": { + "name": "Dryer mode" + }, + "dryer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } + }, + "dryer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]", + "none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]", + "refreshing": "Refreshing", + "weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "dehumidifying": "Dehumidifying", + "ai_drying": "AI drying", + "sanitizing": "Sanitizing", + "internal_care": "Internal care", + "freeze_protection": "Freeze protection", + "continuous_dehumidifying": "Continuous dehumidifying", + "thawing_frozen_inside": "Thawing frozen inside" + } + }, + "equivalent_carbon_dioxide": { + "name": "Equivalent carbon dioxide" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "gas_meter": { + "name": "Gas meter" + }, + "gas_meter_calorific": { + "name": "Gas meter calorific" + }, + "gas_meter_time": { + "name": "Gas meter time" + }, + "infrared_level": { + "name": "Infrared level" + }, + "media_input_source": { + "name": "Media input source", + "state": { + "am": "AM", + "fm": "FM", + "cd": "CD", + "hdmi": "HDMI", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "digitaltv": "Digital TV", + "usb": "USB", + "youtube": "YouTube", + "aux": "AUX", + "bluetooth": "Bluetooth", + "digital": "Digital", + "melon": "Melon", + "wifi": "Wi-Fi", + "network": "Network", + "optical": "Optical", + "coaxial": "Coaxial", + "analog1": "Analog 1", + "analog2": "Analog 2", + "analog3": "Analog 3", + "phono": "Phono" + } + }, + "media_playback_repeat": { + "name": "Media playback repeat" + }, + "media_playback_shuffle": { + "name": "Media playback shuffle" + }, + "media_playback_status": { + "name": "Media playback status" + }, + "odor_sensor": { + "name": "Odor sensor" + }, + "oven_mode": { + "name": "Oven mode", + "state": { + "heating": "Heating", + "grill": "Grill", + "warming": "Warming", + "defrosting": "Defrosting", + "conventional": "Conventional", + "bake": "Bake", + "bottom_heat": "Bottom heat", + "convection_bake": "Convection bake", + "convection_roast": "Convection roast", + "broil": "Broil", + "convection_broil": "Convection broil", + "steam_cook": "Steam cook", + "steam_bake": "Steam bake", + "steam_roast": "Steam roast", + "steam_bottom_heat_plus_convection": "Steam bottom heat plus convection", + "microwave": "Microwave", + "microwave_plus_grill": "Microwave plus grill", + "microwave_plus_convection": "Microwave plus convection", + "microwave_plus_hot_blast": "Microwave plus hot blast", + "microwave_plus_hot_blast_2": "Microwave plus hot blast 2", + "slim_middle": "Slim middle", + "slim_strong": "Slim strong", + "slow_cook": "Slow cook", + "proof": "Proof", + "dehydrate": "Dehydrate", + "others": "Others", + "strong_steam": "Strong steam", + "descale": "Descale", + "rinse": "Rinse" + } + }, + "oven_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "ready": "Ready", + "running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "paused": "[%key:common::state::paused%]" + } + }, + "oven_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cleaning": "Cleaning", + "cooking": "Cooking", + "cooling": "Cooling", + "draining": "Draining", + "preheat": "Preheat", + "ready": "Ready", + "rinsing": "Rinsing", + "finished": "Finished", + "scheduled_start": "Scheduled start", + "warming": "Warming", + "defrosting": "Defrosting", + "sensing": "Sensing", + "searing": "Searing", + "fast_preheat": "Fast preheat", + "scheduled_end": "Scheduled end", + "stone_heating": "Stone heating", + "time_hold_preheat": "Time hold preheat" + } + }, + "oven_setpoint": { + "name": "Set point" + }, + "energy_difference": { + "name": "Energy difference" + }, + "power_energy": { + "name": "Power energy" + }, + "energy_saved": { + "name": "Energy saved" + }, + "power_source": { + "name": "Power source" + }, + "refrigeration_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "robot_cleaner_cleaning_mode": { + "name": "Cleaning mode", + "state": { + "auto": "Auto", + "part": "Partial", + "repeat": "Repeat", + "manual": "Manual", + "stop": "[%key:common::action::stop%]", + "map": "Map" + } + }, + "robot_cleaner_movement": { + "name": "Movement", + "state": { + "homing": "Homing", + "idle": "[%key:common::state::idle%]", + "charging": "[%key:common::state::charging%]", + "alarm": "Alarm", + "off": "[%key:common::state::off%]", + "reserve": "Reserve", + "point": "Point", + "after": "After", + "cleaning": "Cleaning", + "pause": "[%key:common::state::paused%]" + } + }, + "robot_cleaner_turbo_mode": { + "name": "Turbo mode", + "state": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]", + "silence": "Silent", + "extra_silence": "Extra silent" + } + }, + "link_quality": { + "name": "Link quality" + }, + "smoke_detector": { + "name": "Smoke detector", + "state": { + "detected": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::detected%]", + "clear": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::clear%]", + "tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]" + } + }, + "thermostat_cooling_setpoint": { + "name": "Cooling set point" + }, + "thermostat_fan_mode": { + "name": "Fan mode" + }, + "thermostat_heating_setpoint": { + "name": "Heating set point" + }, + "thermostat_mode": { + "name": "Mode" + }, + "thermostat_operating_state": { + "name": "Operating state" + }, + "thermostat_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "x_coordinate": { + "name": "X coordinate" + }, + "y_coordinate": { + "name": "Y coordinate" + }, + "z_coordinate": { + "name": "Z coordinate" + }, + "tv_channel": { + "name": "TV channel" + }, + "tv_channel_name": { + "name": "TV channel name" + }, + "uv_index": { + "name": "UV index" + }, + "washer_mode": { + "name": "Washer mode" + }, + "washer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } + }, + "washer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", + "ai_rinse": "AI rinse", + "ai_spin": "AI spin", + "ai_wash": "AI wash", + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "Delay wash", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]", + "none": "None", + "pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]", + "rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]", + "spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]", + "wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]", + "weight_sensing": "Weight sensing", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "freeze_protection": "Freeze protection" + } + } } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 5cfe4576d6a..f470a90bb39 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,60 +2,71 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.FAN_SPEED, +) + +AC_CAPABILITIES = ( + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "switch") + SmartThingsSwitch( + entry_data.client, device, entry_data.rooms, {Capability.SWITCH} + ) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and not any(capability in device.status[MAIN] for capability in CAPABILITIES) + and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - # Must be able to be turned on/off. - if Capability.switch in capabilities: - return [Capability.switch, Capability.energy_meter, Capability.power_meter] - return None - - class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" + _attr_name = None + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index f665f5e61b3..2e8792140b0 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ATTR_ERRORS, ATTR_REMINDERS, DOMAIN, SMARTTUB_CONTROLLER @@ -43,7 +43,9 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities for the binary sensors in the tub.""" diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index 7f3163834e0..f5759f32fa3 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -17,7 +17,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLLER @@ -42,7 +42,9 @@ HVAC_ACTIONS = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate entity for the thermostat in the tub.""" diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index 532234f4059..dda936aa56a 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_LIGHTS, @@ -28,7 +28,9 @@ from .helpers import get_spa_name async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for any lights in the tub.""" diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index d5102f14437..b8d81db0ea5 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.38"] + "requirements": ["python-smarttub==0.0.39"] } diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 585e8859432..b2bb1170d09 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, SMARTTUB_CONTROLLER @@ -43,7 +43,9 @@ SET_SECONDARY_FILTRATION_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities for the sensors in the tub.""" diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index 6e1cf9bef2a..2dedad8e18a 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -8,7 +8,7 @@ from smarttub import SpaPump from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_TIMEOUT, ATTR_PUMPS, DOMAIN, SMARTTUB_CONTROLLER from .entity import SmartTubEntity @@ -16,7 +16,9 @@ from .helpers import get_spa_name async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities for the pumps on the tub.""" diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0d043804c3d..aab8c6ab3c7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -90,7 +89,7 @@ async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" - coordinator = SmartyCoordinator(hass) + coordinator = SmartyCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index 213cb00d47c..82236a154f0 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -53,7 +53,7 @@ ENTITIES: tuple[SmartyBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Binary Sensor Platform.""" diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py index b8e31cf6fc8..78638561088 100644 --- a/homeassistant/components/smarty/button.py +++ b/homeassistant/components/smarty/button.py @@ -11,7 +11,7 @@ from pysmarty2 import Smarty from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -38,7 +38,7 @@ ENTITIES: tuple[SmartyButtonDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Button Platform.""" diff --git a/homeassistant/components/smarty/coordinator.py b/homeassistant/components/smarty/coordinator.py index d7f3e2452d1..a55c9f2e78f 100644 --- a/homeassistant/components/smarty/coordinator.py +++ b/homeassistant/components/smarty/coordinator.py @@ -22,15 +22,16 @@ class SmartyCoordinator(DataUpdateCoordinator[None]): software_version: str configuration_version: str - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SmartyConfigEntry) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name="Smarty", update_interval=timedelta(seconds=30), ) - self.client = Smarty(host=self.config_entry.data[CONF_HOST]) + self.client = Smarty(host=config_entry.data[CONF_HOST]) async def _async_setup(self) -> None: if not await self.hass.async_add_executor_job(self.client.update): diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 2804f14ee15..07dec85ae47 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -29,7 +29,7 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Fan Platform.""" diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index ca3133d8add..c295647b8e5 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty2"], - "requirements": ["pysmarty2==0.10.1"] + "requirements": ["pysmarty2==0.10.2"] } diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 9d847003a59..fe35f741380 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -85,7 +85,7 @@ ENTITIES: tuple[SmartySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Sensor Platform.""" diff --git a/homeassistant/components/smarty/switch.py b/homeassistant/components/smarty/switch.py index bf5fe80db44..5781bb11680 100644 --- a/homeassistant/components/smarty/switch.py +++ b/homeassistant/components/smarty/switch.py @@ -11,7 +11,7 @@ from pysmarty2 import Smarty from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -42,7 +42,7 @@ ENTITIES: tuple[SmartySwitchDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Switch Platform.""" diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 94bdfcc4559..1869b333071 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,6 +1,5 @@ """Support for the Swedish weather institute weather service.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -10,10 +9,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator + PLATFORMS = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Set up SMHI forecast as config entry.""" # Setting unique id where missing @@ -21,17 +22,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" hass.config_entries.async_update_entry(entry, unique_id=unique_id) + coordinator = SMHIDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Migrate old entry.""" + + if entry.version > 3: + # Downgrade from future version + return False + if entry.version == 1: new_data = { CONF_NAME: entry.data[CONF_NAME], @@ -40,8 +50,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_LONGITUDE: entry.data[CONF_LONGITUDE], }, } + hass.config_entries.async_update_entry(entry, data=new_data, version=2) - if not hass.config_entries.async_update_entry(entry, data=new_data, version=2): - return False + if entry.version == 2: + new_data = entry.data.copy() + new_data.pop(CONF_NAME) + hass.config_entries.async_update_entry(entry, data=new_data, version=3) return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 2992b176f24..387edfc6e11 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -4,12 +4,12 @@ from __future__ import annotations from typing import Any -from smhi.smhi_lib import Smhi, SmhiForecastException +from pysmhi import SmhiForecastException, SMHIPointForecast import voluptuous as vol from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, @@ -26,9 +26,9 @@ async def async_check_location( ) -> bool: """Return true if location is ok.""" session = aiohttp_client.async_get_clientsession(hass) - smhi_api = Smhi(longitude, latitude, session=session) + smhi_api = SMHIPointForecast(str(longitude), str(latitude), session=session) try: - await smhi_api.async_get_forecast() + await smhi_api.async_get_daily_forecast() except SmhiForecastException: return False @@ -38,7 +38,7 @@ async def async_check_location( class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for SMHI component.""" - VERSION = 2 + VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -58,10 +58,6 @@ class SmhiFlowHandler(ConfigFlow, domain=DOMAIN): ): name = HOME_LOCATION_NAME - user_input[CONF_NAME] = ( - HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME - ) - await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() return self.async_create_entry(title=name, data=user_input) diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 11401119227..6cbf928d5e6 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -1,5 +1,7 @@ """Constants in smhi component.""" +from datetime import timedelta +import logging from typing import Final from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -12,3 +14,8 @@ HOME_LOCATION_NAME = "Home" DEFAULT_NAME = "Weather" ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" + +LOGGER = logging.getLogger(__package__) + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=31) +TIMEOUT = 10 diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py new file mode 100644 index 00000000000..511ba8b38d9 --- /dev/null +++ b/homeassistant/components/smhi/coordinator.py @@ -0,0 +1,63 @@ +"""DataUpdateCoordinator for the SMHI integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT + +type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator] + + +@dataclass +class SMHIForecastData: + """Dataclass for SMHI data.""" + + daily: list[SMHIForecast] + hourly: list[SMHIForecast] + + +class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): + """A SMHI Data Update Coordinator.""" + + config_entry: SMHIConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None: + """Initialize the SMHI coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._smhi_api = SMHIPointForecast( + config_entry.data[CONF_LOCATION][CONF_LONGITUDE], + config_entry.data[CONF_LOCATION][CONF_LATITUDE], + session=aiohttp_client.async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> SMHIForecastData: + """Fetch data from SMHI.""" + try: + async with asyncio.timeout(TIMEOUT): + _forecast_daily = await self._smhi_api.async_get_daily_forecast() + _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + except SmhiForecastException as ex: + raise UpdateFailed( + "Failed to retrieve the forecast from the SMHI API" + ) from ex + + return SMHIForecastData( + daily=_forecast_daily, + hourly=_forecast_hourly, + ) diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py new file mode 100644 index 00000000000..89dca3360ca --- /dev/null +++ b/homeassistant/components/smhi/entity.py @@ -0,0 +1,41 @@ +"""Support for the Swedish weather institute weather base entities.""" + +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SMHIDataUpdateCoordinator + + +class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): + """Representation of a base weather entity.""" + + _attr_attribution = "Swedish weather institute (SMHI)" + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + latitude: str, + longitude: str, + coordinator: SMHIDataUpdateCoordinator, + ) -> None: + """Initialize the SMHI base weather entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{latitude}, {longitude}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{latitude}, {longitude}")}, + manufacturer="SMHI", + model="v2", + configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", + ) + self.update_entity_data() + + @abstractmethod + def update_entity_data(self) -> None: + """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 76f9812e815..fc3af634764 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", - "loggers": ["smhi"], - "requirements": ["smhi-pkg==1.0.18"] + "loggers": ["pysmhi"], + "requirements": ["pysmhi==1.0.0"] } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 3d5642a2784..5faef04e03d 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -2,15 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Mapping -from datetime import datetime, timedelta -import logging from typing import Any, Final -import aiohttp -from smhi import Smhi -from smhi.smhi_lib import SmhiForecast, SmhiForecastException +from pysmhi import SMHIForecast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -40,31 +35,26 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, sun -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, dt as dt_util, slugify +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import sun +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT - -_LOGGER = logging.getLogger(__name__) +from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT +from .coordinator import SMHIConfigEntry +from .entity import SmhiWeatherBaseEntity # Used to map condition from API results CONDITION_CLASSES: Final[dict[str, list[int]]] = { @@ -89,119 +79,73 @@ CONDITION_MAP = { for cond_code in cond_codes } -TIMEOUT = 10 -# 5 minutes between retrying connect to API again -RETRY_TIMEOUT = 5 * 60 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: SMHIConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from map location.""" location = config_entry.data - name = slugify(location[CONF_NAME]) - session = aiohttp_client.async_get_clientsession(hass) + coordinator = config_entry.runtime_data entity = SmhiWeather( - location[CONF_NAME], location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], - session=session, + coordinator=coordinator, ) - entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) + entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) - async_add_entities([entity], True) + async_add_entities([entity]) -class SmhiWeather(WeatherEntity): +class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): """Representation of a weather entity.""" - _attr_attribution = "Swedish weather institute (SMHI)" _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_native_pressure_unit = UnitOfPressure.HPA - - _attr_has_entity_name = True - _attr_name = None _attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__( - self, - name: str, - latitude: str, - longitude: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize the SMHI weather entity.""" - self._attr_unique_id = f"{latitude}, {longitude}" - self._forecast_daily: list[SmhiForecast] | None = None - self._forecast_hourly: list[SmhiForecast] | None = None - self._fail_count = 0 - self._smhi_api = Smhi(longitude, latitude, session=session) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{latitude}, {longitude}")}, - manufacturer="SMHI", - model="v2", - name=name, - configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", - ) + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if daily_data := self.coordinator.data.daily: + self._attr_native_temperature = daily_data[0]["temperature"] + self._attr_humidity = daily_data[0]["humidity"] + self._attr_native_wind_speed = daily_data[0]["wind_speed"] + self._attr_wind_bearing = daily_data[0]["wind_direction"] + self._attr_native_visibility = daily_data[0]["visibility"] + self._attr_native_pressure = daily_data[0]["pressure"] + self._attr_native_wind_gust_speed = daily_data[0]["wind_gust"] + self._attr_cloud_coverage = daily_data[0]["total_cloud"] + self._attr_condition = CONDITION_MAP.get(daily_data[0]["symbol"]) + if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( + self.coordinator.hass + ): + self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" - if self._forecast_daily: + if daily_data := self.coordinator.data.daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0].thunder, + ATTR_SMHI_THUNDER_PROBABILITY: daily_data[0]["thunder"], } return None - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self) -> None: - """Refresh the forecast data from SMHI weather API.""" - try: - async with asyncio.timeout(TIMEOUT): - self._forecast_daily = await self._smhi_api.async_get_forecast() - self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() - self._fail_count = 0 - except (TimeoutError, SmhiForecastException): - _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") - self._fail_count += 1 - if self._fail_count < 3: - async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update) - return - - if self._forecast_daily: - self._attr_native_temperature = self._forecast_daily[0].temperature - self._attr_humidity = self._forecast_daily[0].humidity - self._attr_native_wind_speed = self._forecast_daily[0].wind_speed - self._attr_wind_bearing = self._forecast_daily[0].wind_direction - self._attr_native_visibility = self._forecast_daily[0].horizontal_visibility - self._attr_native_pressure = self._forecast_daily[0].pressure - self._attr_native_wind_gust_speed = self._forecast_daily[0].wind_gust - self._attr_cloud_coverage = self._forecast_daily[0].cloudiness - self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0].symbol) - if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( - self.hass - ): - self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT - await self.async_update_listeners(("daily", "hourly")) - - async def retry_update(self, _: datetime) -> None: - """Retry refresh weather forecast.""" - await self.async_update(no_throttle=True) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() def _get_forecast_data( - self, forecast_data: list[SmhiForecast] | None + self, forecast_data: list[SMHIForecast] | None ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -210,34 +154,37 @@ class SmhiWeather(WeatherEntity): data: list[Forecast] = [] for forecast in forecast_data[1:]: - condition = CONDITION_MAP.get(forecast.symbol) + condition = CONDITION_MAP.get(forecast["symbol"]) if condition == ATTR_CONDITION_SUNNY and not sun.is_up( - self.hass, forecast.valid_time.replace(tzinfo=dt_util.UTC) + self.hass, forecast["valid_time"] ): condition = ATTR_CONDITION_CLEAR_NIGHT data.append( { - ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), - ATTR_FORECAST_NATIVE_TEMP: forecast.temperature_max, - ATTR_FORECAST_NATIVE_TEMP_LOW: forecast.temperature_min, - ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.total_precipitation, + ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), + ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast["temperature_min"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.get( + "total_precipitation" + ) + or forecast["mean_precipitation"], ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, - ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, - ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, - ATTR_FORECAST_HUMIDITY: forecast.humidity, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast.wind_gust, - ATTR_FORECAST_CLOUD_COVERAGE: forecast.cloudiness, + ATTR_FORECAST_NATIVE_PRESSURE: forecast["pressure"], + ATTR_FORECAST_WIND_BEARING: forecast["wind_direction"], + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind_speed"], + ATTR_FORECAST_HUMIDITY: forecast["humidity"], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast["wind_gust"], + ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) return data - async def async_forecast_daily(self) -> list[Forecast] | None: + def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self._forecast_daily) + return self._get_forecast_data(self.coordinator.data.daily) - async def async_forecast_hourly(self) -> list[Forecast] | None: + def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self._forecast_hourly) + return self._get_forecast_data(self.coordinator.data.hourly) diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index cbfb8162d63..8f3e675ef6b 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,16 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass +from pysmlight import Api2, Info, Radio -from pysmlight import Api2 - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator +from .coordinator import ( + SmConfigEntry, + SmDataUpdateCoordinator, + SmFirmwareUpdateCoordinator, + SmlightData, +) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -22,25 +24,12 @@ PLATFORMS: list[Platform] = [ ] -@dataclass(kw_only=True) -class SmlightData: - """Coordinator data class.""" - - data: SmDataUpdateCoordinator - firmware: SmFirmwareUpdateCoordinator - - -type SmConfigEntry = ConfigEntry[SmlightData] - - async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Set up SMLIGHT Zigbee from a config entry.""" client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass)) - data_coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST], client) - firmware_coordinator = SmFirmwareUpdateCoordinator( - hass, entry.data[CONF_HOST], client - ) + data_coordinator = SmDataUpdateCoordinator(hass, entry, client) + firmware_coordinator = SmFirmwareUpdateCoordinator(hass, entry, client) await data_coordinator.async_config_entry_first_refresh() await firmware_coordinator.async_config_entry_first_refresh() @@ -61,3 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_radio(info: Info, idx: int) -> Radio: + """Get the radio object from the info.""" + assert info.radios is not None + return info.radios[idx] diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index b1aba3a52fe..ce3457ae81b 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -14,13 +14,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SCAN_INTERNET_INTERVAL -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity SCAN_INTERVAL = SCAN_INTERNET_INTERVAL @@ -56,8 +55,8 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index d82034b87fb..5caf43b7cba 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -14,14 +14,13 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity _LOGGER = logging.getLogger(__name__) @@ -65,8 +64,8 @@ ROUTER = SmButtonDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMLIGHT buttons based on a config entry.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 92b543e0441..fcfc364d983 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -6,14 +6,16 @@ from collections.abc import Mapping from typing import Any from pysmlight import Api2 +from pysmlight.const import Devices from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -34,11 +36,9 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" - host: str - - def __init__(self) -> None: - """Initialize the config flow.""" - self.client: Api2 + _host: str + _device_name: str + client: Api2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -47,10 +47,17 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - self.host = user_input[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = user_input[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: + info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + if not await self._async_check_auth_required(user_input): return await self._async_complete_entry(user_input) except SmlightConnectionError: @@ -71,6 +78,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: if not await self._async_check_auth_required(user_input): + info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") @@ -82,18 +96,17 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" - local_name = discovery_info.hostname[:-1] - node_name = local_name.removesuffix(".local") + mac: str | None = discovery_info.properties.get("mac") + self._device_name = discovery_info.hostname.removesuffix(".local.") + self._host = discovery_info.host - self.host = local_name - self.context["title_placeholders"] = {CONF_NAME: node_name} - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self.context["title_placeholders"] = {CONF_NAME: self._device_name} + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) - mac = discovery_info.properties.get("mac") - # fallback for legacy firmware + # fallback for legacy firmware older than v2.3.x if mac is None: try: info = await self.client.get_info() @@ -103,7 +116,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): mac = info.MAC await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) return await self.async_step_confirm_discovery() @@ -114,8 +127,12 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_HOST] = self.host try: + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + if not await self._async_check_auth_required(user_input): return await self._async_complete_entry(user_input) @@ -129,7 +146,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={"host": self.host}, + description_placeholders={"host": self._device_name}, errors=errors, ) @@ -138,8 +155,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" - self.host = entry_data[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = entry_data[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() @@ -169,6 +186,16 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + # This should never happen since we only listen to DHCP requests + # for configured devices. + return self.async_abort(reason="already_configured") + async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): @@ -183,12 +210,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] ) -> ConfigFlowResult: info = await self.client.get_info() - await self.async_set_unique_id(format_mac(info.MAC)) - self._abort_if_unique_id_configured() - if user_input.get(CONF_HOST) is None: - user_input[CONF_HOST] = self.host + await self.async_set_unique_id( + format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + user_input[CONF_HOST] = self._host assert info.model is not None - title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model + title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 669094b2441..0a45363f8ad 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,7 +9,7 @@ ATTR_MANUFACTURER = "SMLIGHT" DATA_COORDINATOR = "data" FIRMWARE_COORDINATOR = "firmware" -SCAN_FIRMWARE_INTERVAL = timedelta(hours=6) +SCAN_FIRMWARE_INTERVAL = timedelta(hours=24) LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..5a118e7de15 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -4,14 +4,14 @@ from __future__ import annotations from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING from pysmlight import Api2, Info, Sensors from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError -from pysmlight.web import Firmware +from pysmlight.models import FirmwareList -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir @@ -21,8 +21,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_FIRMWARE_INTERVAL, SCAN_INTERVAL -if TYPE_CHECKING: - from . import SmConfigEntry + +@dataclass(kw_only=True) +class SmlightData: + """Coordinator data class.""" + + data: SmDataUpdateCoordinator + firmware: SmFirmwareUpdateCoordinator @dataclass @@ -38,8 +43,11 @@ class SmFwData: """SMLIGHT firmware data stored in the FirmwareUpdateCoordinator.""" info: Info - esp_firmware: list[Firmware] | None - zb_firmware: list[Firmware] | None + esp_firmware: FirmwareList + zb_firmware: list[FirmwareList] + + +type SmConfigEntry = ConfigEntry[SmlightData] class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -47,12 +55,15 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): config_entry: SmConfigEntry - def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SmConfigEntry, client: Api2 + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, - name=f"{DOMAIN}_{host}", + config_entry=config_entry, + name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) @@ -133,9 +144,11 @@ class SmDataUpdateCoordinator(SmBaseDataUpdateCoordinator[SmData]): class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): """Class to manage fetching SMLIGHT firmware update data from cloud.""" - def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SmConfigEntry, client: Api2 + ) -> None: """Initialize the coordinator.""" - super().__init__(hass, host, client) + super().__init__(hass, config_entry, client) self.update_interval = SCAN_FIRMWARE_INTERVAL # only one update can run at a time (core or zibgee) @@ -144,11 +157,30 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + assert info.radios is not None + esp_firmware = None + zb_firmware: list[FirmwareList] = [] + + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware.extend( + [ + await self.client.get_firmware_version( + info.fw_channel, + device=info.model, + mode="zigbee", + zb_type=r.zb_type, + idx=idx, + ) + for idx, r in enumerate(info.radios) + ] + ) + + except SmlightConnectionError as err: + self.async_set_update_error(err) return SmFwData( info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( - info.fw_channel, device=info.model, mode="zigbee" - ), + esp_firmware=esp_firmware, + zb_firmware=zb_firmware, ) diff --git a/homeassistant/components/smlight/diagnostics.py b/homeassistant/components/smlight/diagnostics.py index d303e5803bb..3812175e673 100644 --- a/homeassistant/components/smlight/diagnostics.py +++ b/homeassistant/components/smlight/diagnostics.py @@ -8,7 +8,7 @@ from pysmlight.const import Actions from homeassistant.core import HomeAssistant -from . import SmConfigEntry +from .coordinator import SmConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index cb791ac111b..3f527d1fcd9 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -3,10 +3,15 @@ "name": "SMLIGHT SLZB", "codeowners": ["@tl-sl"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.4"], + "requirements": ["pysmlight==0.2.3"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 1116b99f8c1..57a08d177d4 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -17,13 +17,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from . import SmConfigEntry from .const import UPTIME_DEVIATION -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity @@ -124,7 +123,7 @@ UPTIME: list[SmSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: SmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 1e6a533beef..ca52f6fea38 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Set up SMLIGHT Zigbee Integration", + "description": "Set up SMLIGHT Zigbee integration", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -38,7 +38,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_failed": "[%key:common::config_flow::error::invalid_auth%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unsupported_device": "This device is not yet supported by the SMLIGHT integration" } }, "entity": { @@ -110,7 +111,7 @@ "name": "Zigbee flash mode" }, "reconnect_zigbee_router": { - "name": "Reconnect zigbee router" + "name": "Reconnect Zigbee router" } }, "switch": { diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 1c591e3dbe8..09d2714956c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -17,10 +17,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SmConfigEntry -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity _LOGGER = logging.getLogger(__name__) @@ -68,7 +67,7 @@ SWITCHES: list[SmSwitchEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: SmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize switches for SLZB-06 device.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 147b1d766ef..10d142e6221 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Final +from typing import Any from pysmlight.const import Events as SmEvents from pysmlight.models import Firmware, Info @@ -20,48 +20,70 @@ from homeassistant.components.update import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SmConfigEntry +from . import get_radio from .const import LOGGER -from .coordinator import SmFirmwareUpdateCoordinator, SmFwData +from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: + """Get the latest Zigbee firmware version.""" + + if idx < len(data.zb_firmware): + firmware_list = data.zb_firmware[idx] + if firmware_list: + return firmware_list[0] + return None + + @dataclass(frozen=True, kw_only=True) class SmUpdateEntityDescription(UpdateEntityDescription): """Describes SMLIGHT SLZB-06 update entity.""" - installed_version: Callable[[Info], str | None] - fw_list: Callable[[SmFwData], list[Firmware] | None] + installed_version: Callable[[Info, int], str | None] + latest_version: Callable[[SmFwData, int], Firmware | None] -UPDATE_ENTITIES: Final = [ - SmUpdateEntityDescription( - key="core_update", - translation_key="core_update", - installed_version=lambda x: x.sw_version, - fw_list=lambda x: x.esp_firmware, - ), - SmUpdateEntityDescription( - key="zigbee_update", - translation_key="zigbee_update", - installed_version=lambda x: x.zb_version, - fw_list=lambda x: x.zb_firmware, - ), -] +CORE_UPDATE_ENTITY = SmUpdateEntityDescription( + key="core_update", + translation_key="core_update", + installed_version=lambda x, idx: x.sw_version, + latest_version=lambda x, idx: x.esp_firmware[0] if x.esp_firmware else None, +) + +ZB_UPDATE_ENTITY = SmUpdateEntityDescription( + key="zigbee_update", + translation_key="zigbee_update", + installed_version=lambda x, idx: get_radio(x, idx).zb_version, + latest_version=zigbee_latest_version, +) async def async_setup_entry( - hass: HomeAssistant, entry: SmConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SMLIGHT update entities.""" coordinator = entry.runtime_data.firmware - async_add_entities( - SmUpdateEntity(coordinator, description) for description in UPDATE_ENTITIES + # updates not available for legacy API, user will get repair to update externally + if coordinator.legacy_api == 2: + return + + entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)] + radios = coordinator.data.info.radios + assert radios is not None + + entities.extend( + SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) + for idx, _ in enumerate(radios) ) + async_add_entities(entities) + class SmUpdateEntity(SmEntity, UpdateEntity): """Representation for SLZB-06 update entities.""" @@ -80,42 +102,46 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self, coordinator: SmFirmwareUpdateCoordinator, description: SmUpdateEntityDescription, + idx: int = 0, ) -> None: """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + device = description.key + (f"_{idx}" if idx else "") + self._attr_unique_id = f"{coordinator.unique_id}-{device}" self._finished_event = asyncio.Event() self._firmware: Firmware | None = None self._unload: list[Callable] = [] + self.idx = idx + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update callbacks.""" + self._firmware = self.entity_description.latest_version( + self.coordinator.data, self.idx + ) + if self._firmware: + self.async_write_ha_state() @property def installed_version(self) -> str | None: """Version installed..""" data = self.coordinator.data - version = self.entity_description.installed_version(data.info) - return version if version != "-1" else None + return self.entity_description.installed_version(data.info, self.idx) @property def latest_version(self) -> str | None: """Latest version available for install.""" - data = self.coordinator.data - if self.coordinator.legacy_api == 2: - return None - fw = self.entity_description.fw_list(data) - - if fw and self.entity_description.key == "zigbee_update": - fw = [f for f in fw if f.type == data.info.zb_type] - - if fw: - self._firmware = fw[0] - return self._firmware.ver - - return None + return self._firmware.ver if self._firmware else None def register_callbacks(self) -> None: """Register callbacks for SSE update events.""" @@ -143,9 +169,14 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def release_notes(self) -> str | None: """Return release notes for firmware.""" + if "zigbee" in self.entity_description.key: + notes = f"### {'ZNP' if self.idx else 'EZSP'} Firmware\n\n" + else: + notes = "### Core Firmware\n\n" if self._firmware and self._firmware.notes: - return self._firmware.notes + notes += self._firmware.notes + return notes return None @@ -192,7 +223,7 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self._attr_update_percentage = None self.register_callbacks() - await self.coordinator.client.fw_update(self._firmware) + await self.coordinator.client.fw_update(self._firmware, self.idx) # block until update finished event received await self._finished_event.wait() diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index 821200f68b1..46ee754a1f1 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, GATEWAY, NETWORK_COORDINATOR, SIGNAL_COORDINATOR, SMS_GATEWAY @@ -77,7 +77,7 @@ NETWORK_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all device sensors.""" sms_data = hass.data[DOMAIN][SMS_GATEWAY] diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 5d19a705d87..e86b22690a4 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -34,10 +34,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import client_context from .const import ( diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b853535b525..9c1602494e5 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -11,15 +11,14 @@ from .coordinator import SnapcastUpdateCoordinator 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] - coordinator = SnapcastUpdateCoordinator(hass, host, port) + coordinator = SnapcastUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except OSError as ex: raise ConfigEntryNotReady( - f"Could not connect to Snapcast server at {host}:{port}" + "Could not connect to Snapcast server at " + f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" ) from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 5bb9ae4e51f..4c2f0cb81b7 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -6,6 +6,8 @@ import logging from snapcast.control.server import Snapserver +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,15 +17,20 @@ _LOGGER = logging.getLogger(__name__) class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for pushed data from Snapcast server.""" - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize coordinator.""" + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=f"{host}:{port}", update_interval=None, # Disable update interval as server pushes ) - self._server = Snapserver(hass.loop, host, port, True) self.last_update_success = False diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 0ec27c1ad9c..5f011ca41ee 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -25,7 +25,7 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_LATENCY, @@ -73,7 +73,7 @@ def register_services() -> None: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" diff --git a/homeassistant/components/snips/strings.json b/homeassistant/components/snips/strings.json index 724e1a86477..23b255b05a9 100644 --- a/homeassistant/components/snips/strings.json +++ b/homeassistant/components/snips/strings.json @@ -44,7 +44,7 @@ "fields": { "can_be_enqueued": { "name": "Can be enqueued", - "description": "If True, session waits for an open session to end, if False session is dropped if one is running." + "description": "Whether the session should wait for an open session to end. Otherwise it is dropped if another session is already running." }, "custom_data": { "name": "[%key:component::snips::services::say::fields::custom_data::name%]", diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 3c4a0a0725c..f69c844f191 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( @@ -172,7 +172,7 @@ class SnmpScanner(DeviceScanner): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and res[int(errindex) - 1][0] or "?", + (errindex and res[int(errindex) - 1][0]) or "?", ) return None diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 4586d0600e9..0baecd68ec4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 92e27daed6c..fd405567d60 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -44,7 +44,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -264,7 +264,7 @@ class SnmpSwitch(SwitchEntity): _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), - errindex and restable[-1][int(errindex) - 1] or "?", + (errindex and restable[-1][int(errindex) - 1]) or "?", ) else: for resrow in restable: diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py new file mode 100644 index 00000000000..aaf0c828830 --- /dev/null +++ b/homeassistant/components/snoo/__init__.py @@ -0,0 +1,63 @@ +"""The Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import asyncio +import logging + +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException, SnooDeviceError +from python_snoo.snoo import Snoo + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import SnooConfigEntry, SnooCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Set up Happiest Baby Snoo from a config entry.""" + + snoo = Snoo( + email=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + clientsession=async_get_clientsession(hass), + ) + + try: + await snoo.authorize() + except (SnooAuthException, InvalidSnooAuth) as ex: + raise ConfigEntryNotReady from ex + try: + devices = await snoo.get_devices() + except SnooDeviceError as ex: + raise ConfigEntryNotReady from ex + coordinators: dict[str, SnooCoordinator] = {} + tasks = [] + for device in devices: + coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo) + tasks.append(coordinators[device.serialNumber].setup()) + await asyncio.gather(*tasks) + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Unload a config entry.""" + disconnects = await asyncio.gather( + *(coordinator.snoo.disconnect() for coordinator in entry.runtime_data.values()), + return_exceptions=True, + ) + for disconnect in disconnects: + if isinstance(disconnect, Exception): + _LOGGER.warning( + "Failed to disconnect a logger with exception: %s", disconnect + ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/snoo/config_flow.py b/homeassistant/components/snoo/config_flow.py new file mode 100644 index 00000000000..986ef6a0071 --- /dev/null +++ b/homeassistant/components/snoo/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for the Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException +from python_snoo.snoo import Snoo +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class SnooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Happiest Baby Snoo.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + hub = Snoo( + email=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + clientsession=async_get_clientsession(self.hass), + ) + + try: + tokens = await hub.authorize() + except SnooAuthException: + errors["base"] = "cannot_connect" + except InvalidSnooAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception %s") + errors["base"] = "unknown" + else: + user_uuid = jwt.decode( + tokens.aws_access, options={"verify_signature": False} + )["username"] + await self.async_set_unique_id(user_uuid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/snoo/const.py b/homeassistant/components/snoo/const.py new file mode 100644 index 00000000000..ff8afe25056 --- /dev/null +++ b/homeassistant/components/snoo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Happiest Baby Snoo integration.""" + +DOMAIN = "snoo" diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py new file mode 100644 index 00000000000..bc06d20955c --- /dev/null +++ b/homeassistant/components/snoo/coordinator.py @@ -0,0 +1,39 @@ +"""Support for Snoo Coordinators.""" + +import logging + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.snoo import Snoo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +type SnooConfigEntry = ConfigEntry[dict[str, SnooCoordinator]] + +_LOGGER = logging.getLogger(__name__) + + +class SnooCoordinator(DataUpdateCoordinator[SnooData]): + """Snoo coordinator.""" + + config_entry: SnooConfigEntry + + def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None: + """Set up Snoo Coordinator.""" + super().__init__( + hass, + name=device.name, + logger=_LOGGER, + ) + self.device_unique_id = device.serialNumber + self.device = device + self.sensor_data_set: bool = False + self.snoo = snoo + + async def setup(self) -> None: + """Perform setup needed on every coordintaor creation.""" + await self.snoo.subscribe(self.device, self.async_set_updated_data) + # After we subscribe - get the status so that we have something to start with. + # We only need to do this once. The device will auto update otherwise. + await self.snoo.get_status(self.device) diff --git a/homeassistant/components/snoo/entity.py b/homeassistant/components/snoo/entity.py new file mode 100644 index 00000000000..25f54344674 --- /dev/null +++ b/homeassistant/components/snoo/entity.py @@ -0,0 +1,37 @@ +"""Base entity for the Snoo integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SnooCoordinator + + +class SnooDescriptionEntity(CoordinatorEntity[SnooCoordinator]): + """Defines an Snoo entity that uses a description.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SnooCoordinator, description: EntityDescription + ) -> None: + """Initialize the Snoo entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_unique_id)}, + name=self.device.name, + manufacturer="Happiest Baby", + model="Snoo", + serial_number=self.device.serialNumber, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data is not None and super().available diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json new file mode 100644 index 00000000000..3dca8cfe7dd --- /dev/null +++ b/homeassistant/components/snoo/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "snoo", + "name": "Happiest Baby Snoo", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/snoo", + "iot_class": "cloud_push", + "loggers": ["snoo"], + "quality_scale": "bronze", + "requirements": ["python-snoo==0.6.0"] +} diff --git a/homeassistant/components/snoo/quality_scale.yaml b/homeassistant/components/snoo/quality_scale.yaml new file mode 100644 index 00000000000..f10bccb131a --- /dev/null +++ b/homeassistant/components/snoo/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: done + comment: | + There are no common patterns currenty. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/snoo/sensor.py b/homeassistant/components/snoo/sensor.py new file mode 100644 index 00000000000..e45b2b88592 --- /dev/null +++ b/homeassistant/components/snoo/sensor.py @@ -0,0 +1,71 @@ +"""Support for Snoo Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooStates + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSensorEntityDescription(SensorEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], StateType] + + +SENSOR_DESCRIPTIONS: list[SnooSensorEntityDescription] = [ + SnooSensorEntityDescription( + key="state", + translation_key="state", + value_fn=lambda data: data.state_machine.state.name, + device_class=SensorDeviceClass.ENUM, + options=[e.name for e in SnooStates], + ), + SnooSensorEntityDescription( + key="time_left", + translation_key="time_left", + value_fn=lambda data: data.state_machine.time_left_timestamp, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSensor(coordinator, description) + for coordinator in coordinators.values() + for description in SENSOR_DESCRIPTIONS + ) + + +class SnooSensor(SnooDescriptionEntity, SensorEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json new file mode 100644 index 00000000000..567fa30fca7 --- /dev/null +++ b/homeassistant/components/snoo/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Snoo username or email", + "password": "Your Snoo password" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "state": { + "name": "State", + "state": { + "baseline": "Baseline", + "level1": "Level 1", + "level2": "Level 2", + "level3": "Level 3", + "level4": "Level 4", + "stop": "Stopped", + "pretimeout": "Pre-timeout", + "timeout": "Timeout" + } + }, + "time_left": { + "name": "Time left" + } + } + } +} diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index bfe773b4780..ce804450cab 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -38,7 +38,9 @@ from .models import SnoozConfigurationData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Snooz device from a config entry.""" diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index 94ca434e589..ca252b2117c 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -27,25 +27,25 @@ "services": { "transition_on": { "name": "Transition on", - "description": "Transitions to a target volume level over time.", + "description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.", "fields": { "duration": { "name": "Transition duration", - "description": "Time it takes to reach the target volume level." + "description": "Time to transition to the target volume." }, "volume": { "name": "Target volume", - "description": "If not specified, the volume level is read from the device." + "description": "Relative volume level. If not specified, the setting on the device is used." } } }, "transition_off": { "name": "Transition off", - "description": "Transitions volume off over time.", + "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.", "fields": { "duration": { "name": "[%key:component::snooz::services::transition_on::fields::duration::name%]", - "description": "Time it takes to turn off." + "description": "Time to complete the transition." } } } diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index d37cf355fce..44f015eedeb 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import date, datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from aiosolaredge import SolarEdge from stringcase import snakecase @@ -21,13 +21,22 @@ from .const import ( POWER_FLOW_UPDATE_DELAY, ) +if TYPE_CHECKING: + from .types import SolarEdgeConfigEntry + class SolarEdgeDataService(ABC): """Get and update the latest data.""" coordinator: DataUpdateCoordinator[None] - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the data object.""" self.api = api self.site_id = site_id @@ -36,6 +45,7 @@ class SolarEdgeDataService(ABC): self.attributes: dict[str, Any] = {} self.hass = hass + self.config_entry = config_entry @callback def async_setup(self) -> None: @@ -43,6 +53,7 @@ class SolarEdgeDataService(ABC): self.coordinator = DataUpdateCoordinator( self.hass, LOGGER, + config_entry=self.config_entry, name=str(self), update_method=self.async_update_data, update_interval=self.update_interval, @@ -174,9 +185,15 @@ class SolarEdgeInventoryDataService(SolarEdgeDataService): class SolarEdgeEnergyDetailsService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the power flow data service.""" - super().__init__(hass, api, site_id) + super().__init__(hass, config_entry, api, site_id) self.unit = None @@ -234,9 +251,15 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService): class SolarEdgePowerFlowDataService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the power flow data service.""" - super().__init__(hass, api, site_id) + super().__init__(hass, config_entry, api, site_id) self.unit = None diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 4b2398d15c2..acb86f875c9 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -201,12 +201,12 @@ SENSOR_TYPES = [ async def async_setup_entry( hass: HomeAssistant, entry: SolarEdgeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add an solarEdge entry.""" # Add the needed sensors to hass api = entry.runtime_data[DATA_API_CLIENT] - sensor_factory = SolarEdgeSensorFactory(hass, entry.data[CONF_SITE_ID], api) + sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() @@ -222,14 +222,20 @@ async def async_setup_entry( class SolarEdgeSensorFactory: """Factory which creates sensors based on the sensor_key.""" - def __init__(self, hass: HomeAssistant, site_id: str, api: SolarEdge) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + site_id: str, + api: SolarEdge, + ) -> None: """Initialize the factory.""" - details = SolarEdgeDetailsDataService(hass, api, site_id) - overview = SolarEdgeOverviewDataService(hass, api, site_id) - inventory = SolarEdgeInventoryDataService(hass, api, site_id) - flow = SolarEdgePowerFlowDataService(hass, api, site_id) - energy = SolarEdgeEnergyDetailsService(hass, api, site_id) + details = SolarEdgeDetailsDataService(hass, config_entry, api, site_id) + overview = SolarEdgeOverviewDataService(hass, config_entry, api, site_id) + inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) + flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) + energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) self.all_services = (details, overview, inventory, flow, energy) diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index a7940aa34b5..80c418ef132 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index 5937c8a496d..7ad1ec8e547 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -2,18 +2,16 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .const import CONF_HAS_PWD -from .coordinator import SolarLogCoordinator +from .coordinator import SolarlogConfigEntry, SolarLogCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type SolarlogConfigEntry = ConfigEntry[SolarLogCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) -> bool: diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 11f268db32a..6292b1332d7 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING from urllib.parse import ParseResult, urlparse from solarlog_cli.solarlog_connector import SolarLogConnector @@ -16,11 +15,12 @@ from solarlog_cli.solarlog_exceptions import ( ) from solarlog_cli.solarlog_models import SolarlogData +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify @@ -28,30 +28,35 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import SolarlogConfigEntry +type SolarlogConfigEntry = ConfigEntry[SolarLogCoordinator] class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): """Get and update the latest data.""" - def __init__(self, hass: HomeAssistant, entry: SolarlogConfigEntry) -> None: + config_entry: SolarlogConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SolarlogConfigEntry) -> None: """Initialize the data object.""" super().__init__( - hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + hass, + _LOGGER, + config_entry=config_entry, + name="SolarLog", + update_interval=timedelta(seconds=60), ) self.new_device_callbacks: list[Callable[[int], None]] = [] self._devices_last_update: set[tuple[int, str]] = set() - host_entry = entry.data[CONF_HOST] - password = entry.data.get("password", "") + host_entry = config_entry.data[CONF_HOST] + password = config_entry.data.get("password", "") url = urlparse(host_entry, "http") netloc = url.netloc or url.path path = url.path if url.netloc else "" url = ParseResult("http", netloc, path, *url[3:]) - self.unique_id = entry.entry_id + self.unique_id = config_entry.entry_id self.host = url.geturl() self.solarlog = SolarLogConnector( diff --git a/homeassistant/components/solarlog/diagnostics.py b/homeassistant/components/solarlog/diagnostics.py index 02f6c96edc2..c99222542ea 100644 --- a/homeassistant/components/solarlog/diagnostics.py +++ b/homeassistant/components/solarlog/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import SolarlogConfigEntry +from .coordinator import SolarlogConfigEntry TO_REDACT = [ CONF_HOST, diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index bcff5d57e1b..c4bb119c006 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -21,10 +21,10 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import SolarlogConfigEntry +from .coordinator import SolarlogConfigEntry from .entity import SolarLogCoordinatorEntity, SolarLogInverterEntity @@ -276,7 +276,7 @@ INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SolarlogConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add solarlog entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py index e6c60667869..5baead641fc 100644 --- a/homeassistant/components/solax/config_flow.py +++ b/homeassistant/components/solax/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 925f11e4c65..5509901ae02 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -1,7 +1,7 @@ { "domain": "solax", "name": "SolaX Power", - "codeowners": ["@squishykid"], + "codeowners": ["@squishykid", "@Darsstar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 6ca0bac0c38..1cdec0389fe 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SolaxConfigEntry @@ -89,7 +89,7 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SolaxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Entry setup.""" api = entry.runtime_data.api diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 9ffe5539ff3..127b51338ee 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import API, DEVICES, DOMAIN, HOST, PORT diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 50f7d34e406..15aa21b1f48 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API, DEVICES, DOMAIN from .entity import SomaEntity @@ -24,7 +24,7 @@ from .utils import is_api_response_success async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma cover platform.""" @@ -76,7 +76,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) self.set_position(0) @@ -85,7 +85,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], -100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) self.set_position(100) @@ -94,7 +94,7 @@ class SomaTilt(SomaEntity, CoverEntity): response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -109,7 +109,7 @@ class SomaTilt(SomaEntity, CoverEntity): if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) self.set_position(kwargs[ATTR_TILT_POSITION]) @@ -152,7 +152,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while closing the cover ({self.name}): {response["msg"]}' + f"Error while closing the cover ({self.name}): {response['msg']}" ) def open_cover(self, **kwargs: Any) -> None: @@ -160,7 +160,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.set_shade_position(self.device["mac"], 0) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while opening the cover ({self.name}): {response["msg"]}' + f"Error while opening the cover ({self.name}): {response['msg']}" ) def stop_cover(self, **kwargs: Any) -> None: @@ -168,7 +168,7 @@ class SomaShade(SomaEntity, CoverEntity): response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): raise HomeAssistantError( - f'Error while stopping the cover ({self.name}): {response["msg"]}' + f"Error while stopping the cover ({self.name}): {response['msg']}" ) # Set cover position to some value where up/down are both enabled self.set_position(50) @@ -182,7 +182,7 @@ class SomaShade(SomaEntity, CoverEntity): if not is_api_response_success(response): raise HomeAssistantError( f"Error while setting the cover position ({self.name}):" - f' {response["msg"]}' + f" {response['msg']}" ) async def async_update(self) -> None: diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py index f9824d107b1..4b2fcee5405 100644 --- a/homeassistant/components/soma/entity.py +++ b/homeassistant/components/soma/entity.py @@ -71,7 +71,7 @@ class SomaEntity(Entity): self.api_is_available = True @property - def available(self): + def available(self) -> bool: """Return true if the last API commands returned successfully.""" return self.is_available diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 806886009f3..839f28e9a65 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from .const import API, DEVICES, DOMAIN @@ -18,7 +18,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma sensor platform.""" diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index c2d85160175..a806d581aec 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -9,7 +9,6 @@ from typing import Any from somfy_mylink_synergy import SomfyMyLinkSynergy import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -21,6 +20,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_REVERSE, @@ -69,7 +69,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 8c64e58362b..5b888ea4b96 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -7,7 +7,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverS from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -29,7 +29,7 @@ MYLINK_COVER_TYPE_TO_DEVICE_CLASS = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover and configure Somfy covers.""" reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {}) diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 7718ff799f5..960227ff0da 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -67,13 +67,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { - "upcoming": CalendarDataUpdateCoordinator(hass, host_configuration, sonarr), - "commands": CommandsDataUpdateCoordinator(hass, host_configuration, sonarr), - "diskspace": DiskSpaceDataUpdateCoordinator(hass, host_configuration, sonarr), - "queue": QueueDataUpdateCoordinator(hass, host_configuration, sonarr), - "series": SeriesDataUpdateCoordinator(hass, host_configuration, sonarr), - "status": StatusDataUpdateCoordinator(hass, host_configuration, sonarr), - "wanted": WantedDataUpdateCoordinator(hass, host_configuration, sonarr), + "upcoming": CalendarDataUpdateCoordinator( + hass, entry, host_configuration, sonarr + ), + "commands": CommandsDataUpdateCoordinator( + hass, entry, host_configuration, sonarr + ), + "diskspace": DiskSpaceDataUpdateCoordinator( + hass, entry, host_configuration, sonarr + ), + "queue": QueueDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + "series": SeriesDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + "status": StatusDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + "wanted": WantedDataUpdateCoordinator(hass, entry, host_configuration, sonarr), } # Temporary, until we add diagnostic entities _version = None diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index 2d807bcf140..a73ef838590 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DOMAIN, LOGGER @@ -48,6 +48,7 @@ class SonarrDataUpdateCoordinator(DataUpdateCoordinator[SonarrDataT]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, host_configuration: PyArrHostConfiguration, api_client: SonarrClient, ) -> None: @@ -55,6 +56,7 @@ class SonarrDataUpdateCoordinator(DataUpdateCoordinator[SonarrDataT]): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index bdb647de39c..983ac76d93e 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -23,9 +23,9 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator @@ -67,7 +67,7 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) identifier = ( - f"S{item.episode.seasonNumber:02d}E{item.episode. episodeNumber:02d}" + f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" ) attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" return attrs @@ -90,7 +90,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "commands": SonarrSensorEntityDescription[list[Command]]( key="commands", translation_key="commands", - native_unit_of_measurement="Commands", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: {c.name: c.status for c in data}, @@ -107,7 +106,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "queue": SonarrSensorEntityDescription[SonarrQueue]( key="queue", translation_key="queue", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_queue_attr, @@ -115,12 +113,12 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "series": SonarrSensorEntityDescription[list[SonarrSeries]]( key="series", translation_key="series", - native_unit_of_measurement="Series", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: { i.title: ( - f"{getattr(i.statistics,'episodeFileCount', 0)}/{getattr(i.statistics, 'episodeCount', 0)} Episodes" + f"{getattr(i.statistics, 'episodeFileCount', 0)}/" + f"{getattr(i.statistics, 'episodeCount', 0)} Episodes" ) for i in data }, @@ -128,7 +126,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]]( key="upcoming", translation_key="upcoming", - native_unit_of_measurement="Episodes", value_fn=len, attributes_fn=lambda data: { e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data @@ -137,7 +134,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( key="wanted", translation_key="wanted", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_wanted_attr, @@ -148,7 +144,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 5b17f3283e8..940ec650270 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -37,22 +37,27 @@ "entity": { "sensor": { "commands": { - "name": "Commands" + "name": "Commands", + "unit_of_measurement": "commands" }, "diskspace": { "name": "Disk space" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "episodes" }, "series": { - "name": "Shows" + "name": "Shows", + "unit_of_measurement": "series" }, "upcoming": { - "name": "Upcoming" + "name": "Upcoming", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" }, "wanted": { - "name": "Wanted" + "name": "Wanted", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" } } } diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 1c13013108f..e71454f0aa8 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -9,9 +9,13 @@ from urllib.parse import urlparse from songpal import Device, SongpalException import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_ENDPOINT, DOMAIN @@ -99,15 +103,15 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered Songpal device.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() _LOGGER.debug("Discovered: %s", discovery_info) - friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + friendly_name = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] hostname = urlparse(discovery_info.ssdp_location).hostname scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"] endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"] diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index b4063b09691..3fc75d712a7 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -34,7 +34,10 @@ from homeassistant.helpers import ( entity_platform, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING @@ -63,7 +66,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up songpal media player.""" name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 82e4a5ebfba..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,6 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -500,9 +505,9 @@ class SonosDiscoveryManager: @callback def _async_ssdp_discovered_player( - self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -521,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) @@ -529,7 +534,7 @@ class SonosDiscoveryManager: def async_discovered_player( self, source: str, - info: ssdp.SsdpServiceInfo, + info: SsdpServiceInfo, discovered_ip: str, uid: str, boot_seqnum: str | int | None, diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index a18598fc545..afbff8baa6d 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -66,7 +66,10 @@ class SonosAlarms(SonosHouseholdCoordinator): event_id = event.variables["alarm_list_version"].split(":")[-1] event_id = int(event_id) async with self.cache_update_lock: - if event_id <= self.last_processed_event_id: + if ( + self.last_processed_event_id + and event_id <= self.last_processed_event_id + ): # Skip updates if this event_id has already been seen return speaker.event_stats.process(event) diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 2c1e8af9961..322beaed092 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR from .entity import SonosEntity @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index a8ace6e35c5..057cdb8ec08 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -1,12 +1,12 @@ """Config flow for SONOS.""" from collections.abc import Awaitable -import dataclasses -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN, UPNP_ST from .helpers import hostname_to_uid @@ -25,21 +25,21 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO super().__init__(DOMAIN, "Sonos", _async_has_devices) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf.""" hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") - await self.async_set_unique_id(self._domain, raise_on_progress=False) - host = discovery_info.host - mdns_name = discovery_info.name - properties = discovery_info.properties - boot_seqnum = properties.get("bootseq") - model = properties.get("model") - uid = hostname_to_uid(hostname) if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): + host = discovery_info.host + mdns_name = discovery_info.name + properties = discovery_info.properties + boot_seqnum = properties.get("bootseq") + model = properties.get("model") + uid = hostname_to_uid(hostname) discovery_manager.async_discovered_player( "Zeroconf", properties, host, uid, boot_seqnum, model, mdns_name ) - return await self.async_step_discovery(dataclasses.asdict(discovery_info)) + await self.async_set_unique_id(self._domain, raise_on_progress=False) + return await self.async_step_discovery({}) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 610a68afedf..8fb704cbfbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -170,6 +170,7 @@ MODELS_TV_ONLY = ( "BEAM", "PLAYBAR", "PLAYBASE", + "ULTRA", ) MODELS_LINEIN_AND_TV = ("AMP",) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 98dc8b8b752..a9a76b3b4d0 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -8,7 +8,7 @@ import logging from soco.core import SoCo -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 76a7d0bfa91..5bbfc33ae5b 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -7,8 +7,8 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", - "loggers": ["soco"], - "requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"], + "loggers": ["soco", "sonos_websocket"], + "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 8d0917c5dba..0c66484202f 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -46,7 +46,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, cal from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import UnjoinData, media_browser @@ -108,7 +108,7 @@ ATTR_QUEUE_POSITION = "queue_position" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 272218cc01e..c23ba51a877 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_LEVELS from .entity import SonosEntity @@ -70,7 +70,7 @@ LEVEL_FROM_NUMBER = {"balance": _balance_from_number} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sonos number platform from a config entry.""" diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index a089c09b33c..d888ee669bb 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( SONOS_CREATE_AUDIO_FORMAT_SENSOR, @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index d3774e85213..07d2e2db4e0 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -87,7 +87,7 @@ "services": { "snapshot": { "name": "Snapshot", - "description": "Takes a snapshot of the media player.", + "description": "Takes a snapshot of a media player.", "fields": { "entity_id": { "name": "Entity", @@ -95,13 +95,13 @@ }, "with_group": { "name": "With group", - "description": "True or False. Also snapshot the group layout." + "description": "Whether the snapshot should include the group layout and the state of other speakers in the group." } } }, "restore": { "name": "Restore", - "description": "Restores a snapshot of the media player.", + "description": "Restores a snapshot of a media player.", "fields": { "entity_id": { "name": "Entity", @@ -109,7 +109,7 @@ }, "with_group": { "name": "[%key:component::sonos::services::snapshot::fields::with_group::name%]", - "description": "True or False. Also restore the group layout." + "description": "Whether the group layout and the state of other speakers in the group should also be restored." } } }, @@ -129,7 +129,7 @@ }, "play_queue": { "name": "Play queue", - "description": "Start playing the queue from the first item.", + "description": "Starts playing the queue from the first item.", "fields": { "queue_position": { "name": "Queue position", @@ -153,23 +153,23 @@ "fields": { "alarm_id": { "name": "Alarm ID", - "description": "ID for the alarm to be updated." + "description": "The ID of the alarm to be updated." }, "time": { "name": "Time", - "description": "Set time for the alarm." + "description": "The time for the alarm." }, "volume": { "name": "Volume", - "description": "Set alarm volume level." + "description": "The alarm volume level." }, "enabled": { "name": "Alarm enabled", - "description": "Enable or disable the alarm." + "description": "Whether or not to enable the alarm." }, "include_linked_zones": { "name": "Include linked zones", - "description": "Enable or disable including grouped rooms." + "description": "Whether the alarm also plays on grouped players." } } }, diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 4bf5487b1a6..ce4774a4138 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -15,7 +15,7 @@ from homeassistant.const import ATTR_TIME, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_change from .const import ( @@ -74,7 +74,7 @@ WEEKEND_DAYS = (0, 6) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index e018c06e050..f024c4ef4f7 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index c35c1e6f9c3..49750bc9baf 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index af45b8f6bdc..f30065d1157 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -6,10 +6,10 @@ from libsoundtouch import soundtouch_device from requests import RequestException import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 5edd42b931a..c540b8dfd64 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers.device_registry import ( DeviceInfo, format_mac, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -47,7 +47,7 @@ ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Bose SoundTouch media player based on a config entry.""" device = hass.data[DOMAIN][entry.entry_id].device diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 9fc11f7788a..2544eeb14a9 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -27,8 +27,8 @@ "description": "Plays on all Bose SoundTouch devices.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." + "name": "Leader", + "description": "The media player entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." } } }, @@ -37,40 +37,40 @@ "description": "Creates a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that will coordinate the multi-room zone. Platform dependent." + "name": "Leader", + "description": "The media player entity that will coordinate the multi-room zone. Platform dependent." }, "slaves": { - "name": "Slaves", - "description": "Name of slaves entities to add to the new zone." + "name": "Follower", + "description": "The media player entities to add to the new zone." } } }, "add_zone_slave": { - "name": "Add zone slave", - "description": "Adds a slave to a SoundTouch multi-room zone.", + "name": "Add zone follower", + "description": "Adds media players to a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", - "description": "Name of the master entity that is coordinating the multi-room zone. Platform dependent." + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", + "description": "The media player entity that is coordinating the multi-room zone. Platform dependent." }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to add to the existing zone." + "description": "The media player entities to add to the existing zone." } } }, "remove_zone_slave": { - "name": "Remove zone slave", - "description": "Removes a slave from the SoundTouch multi-room zone.", + "name": "Remove zone follower", + "description": "Removes media players from a SoundTouch multi-room zone.", "fields": { "master": { - "name": "Master", + "name": "[%key:component::soundtouch::services::create_zone::fields::master::name%]", "description": "[%key:component::soundtouch::services::add_zone_slave::fields::master::description%]" }, "slaves": { "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", - "description": "Name of slaves entities to remove from the existing zone." + "description": "The media player entities to remove from the existing zone." } } } diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 90281fe311c..6ef643488ad 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -5,6 +5,7 @@ import math import voluptuous as vol +from homeassistant import core as ha from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import ( ATTR_ENTITY_ID, @@ -21,11 +22,10 @@ from homeassistant.const import ( CONF_STATE, CONF_URL, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util ATTR_ADDRESS = "address" ATTR_SPACEFED = "spacefed" diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py index 3d9467f2041..2fed542e382 100644 --- a/homeassistant/components/spc/__init__.py +++ b/homeassistant/components/spc/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index e4c51ab7aa0..e4f439013c6 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -6,18 +6,16 @@ from functools import partial import speedtest -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.start import async_at_started -from .coordinator import SpeedTestDataCoordinator +from .coordinator import SpeedTestConfigEntry, SpeedTestDataCoordinator PLATFORMS = [Platform.SENSOR] -type SpeedTestConfigEntry = ConfigEntry[SpeedTestDataCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: SpeedTestConfigEntry @@ -49,11 +47,15 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: SpeedTestConfigEntry +) -> bool: """Unload SpeedTest Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: SpeedTestConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 3bfd4eb6e4a..4fbca5e0d29 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.core import callback -from . import SpeedTestConfigEntry from .const import ( CONF_SERVER_ID, CONF_SERVER_NAME, @@ -17,6 +16,7 @@ from .const import ( DEFAULT_SERVER, DOMAIN, ) +from .coordinator import SpeedTestConfigEntry class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/speedtestdotnet/coordinator.py b/homeassistant/components/speedtestdotnet/coordinator.py index 299652ba0bd..1308cb1d825 100644 --- a/homeassistant/components/speedtestdotnet/coordinator.py +++ b/homeassistant/components/speedtestdotnet/coordinator.py @@ -14,23 +14,28 @@ from .const import CONF_SERVER_ID, DEFAULT_SCAN_INTERVAL, DEFAULT_SERVER, DOMAIN _LOGGER = logging.getLogger(__name__) +type SpeedTestConfigEntry = ConfigEntry[SpeedTestDataCoordinator] + class SpeedTestDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get the latest data from speedtest.net.""" - config_entry: ConfigEntry + config_entry: SpeedTestConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: speedtest.Speedtest + self, + hass: HomeAssistant, + config_entry: SpeedTestConfigEntry, + api: speedtest.Speedtest, ) -> None: """Initialize the data object.""" self.hass = hass - self.config_entry = config_entry self.api = api self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( self.hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=DEFAULT_SCAN_INTERVAL), ) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 10da1dc93af..c2b7a6de28c 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -15,11 +15,10 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfDataRate, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SpeedTestConfigEntry from .const import ( ATTR_BYTES_RECEIVED, ATTR_BYTES_SENT, @@ -30,7 +29,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) -from .coordinator import SpeedTestDataCoordinator +from .coordinator import SpeedTestConfigEntry, SpeedTestDataCoordinator @dataclass(frozen=True) @@ -70,7 +69,7 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SpeedTestConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Speedtestdotnet sensors.""" speedtest_coordinator = config_entry.runtime_data diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index 4b138ec77a8..c0d85c02dd4 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 4294020eeee..6ef8fed78d6 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -19,9 +19,8 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 37580ac432d..1c4ea961ce3 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING import aiohttp from spotifyaio import Device, SpotifyClient, SpotifyConnectionError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -32,11 +31,11 @@ from .util import ( PLATFORMS = [Platform.MEDIA_PLAYER] __all__ = [ - "async_browse_media", "DOMAIN", - "spotify_uri_from_media_browser_url", + "async_browse_media", "is_spotify_media_type", "resolve_spotify_media_type", + "spotify_uri_from_media_browser_url", ] @@ -63,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b spotify.refresh_token_function = _refresh_token - coordinator = SpotifyCoordinator(hass, spotify) + coordinator = SpotifyCoordinator(hass, entry, spotify) await coordinator.async_config_entry_first_refresh() @@ -92,6 +91,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: """Unload Spotify config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 81cdfdfb3cf..686431da249 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -14,7 +14,7 @@ from spotifyaio import ( SpotifyClient, Track, ) -from spotifyaio.models import ItemType, SimplifiedEpisode +from spotifyaio.models import Episode, ItemType, SimplifiedEpisode import yarl from homeassistant.components.media_player import ( @@ -226,17 +226,17 @@ async def async_browse_media( if media_content_id is None or not media_content_id.startswith(MEDIA_PLAYER_PREFIX): raise BrowseError("Invalid Spotify URL specified") - # Check for config entry specifier, and extract Spotify URI + # The config entry id is the host name of the URL, the Spotify URI is the name parsed_url = yarl.URL(media_content_id) - host = parsed_url.host + config_entry_id = parsed_url.host if ( - host is None + config_entry_id is None # config entry ids can be upper or lower case. Yarl always returns host # names in lower case, so we need to look for the config entry in both or ( - entry := hass.config_entries.async_get_entry(host) - or hass.config_entries.async_get_entry(host.upper()) + entry := hass.config_entries.async_get_entry(config_entry_id) + or hass.config_entries.async_get_entry(config_entry_id.upper()) ) is None or entry.state is not ConfigEntryState.LOADED @@ -363,7 +363,7 @@ async def build_item_response( # noqa: C901 items.append(_get_track_item_payload(playlist_item.track)) elif playlist_item.track.type is ItemType.EPISODE: if TYPE_CHECKING: - assert isinstance(playlist_item.track, SimplifiedEpisode) + assert isinstance(playlist_item.track, Episode) items.append(_get_episode_item_payload(playlist_item.track)) elif media_content_type == MediaType.ALBUM: if album := await spotify.get_album(media_content_id): diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 099b1cb3ca8..2d5fffebb7b 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -18,7 +18,7 @@ from spotifyaio import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -31,6 +31,9 @@ _LOGGER = logging.getLogger(__name__) type SpotifyConfigEntry = ConfigEntry[SpotifyData] +UPDATE_INTERVAL = timedelta(seconds=30) + + @dataclass class SpotifyCoordinatorData: """Class to hold Spotify data.""" @@ -53,13 +56,19 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): current_user: UserProfile config_entry: SpotifyConfigEntry - def __init__(self, hass: HomeAssistant, client: SpotifyClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SpotifyConfigEntry, + client: SpotifyClient, + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=UPDATE_INTERVAL, ) self.client = client self._playlist: Playlist | None = None @@ -73,6 +82,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): raise UpdateFailed("Error communicating with Spotify API") from err async def _async_update_data(self) -> SpotifyCoordinatorData: + self.update_interval = UPDATE_INTERVAL try: current = await self.client.get_playback() except SpotifyConnectionError as err: @@ -120,6 +130,13 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): ) self._playlist = None self._checked_playlist_id = None + if current.is_playing and current.progress_ms is not None: + assert current.item is not None + time_left = timedelta( + milliseconds=current.item.duration_ms - current.progress_ms + ) + if time_left < UPDATE_INTERVAL: + self.update_interval = time_left + timedelta(seconds=1) return SpotifyCoordinatorData( current_playback=current, position_updated_at=position_updated_at, diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 20a634efb42..d6265cbc39d 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -31,7 +31,7 @@ from homeassistant.components.media_player import ( RepeatMode, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .browse_media import async_browse_media_internal @@ -70,7 +70,7 @@ AFTER_REQUEST_SLEEP = 1 async def async_setup_entry( hass: HomeAssistant, entry: SpotifyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 71e3671ce96..1b9e8502209 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 01c95d6c5e4..2b00a5b0d65 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,9 +1,10 @@ { "domain": "sql", "name": "SQL", + "after_dependencies": ["recorder"], "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.38", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 312b0cd345e..a7b488dd521 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -36,7 +36,10 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -101,7 +104,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SQL sensor from config entry.""" @@ -178,7 +183,7 @@ async def async_setup_sensor( unique_id: str | None, db_url: str, yaml: bool, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, ) -> None: """Set up the SQL sensor.""" try: diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index cd36ccf7731..ac861e72b72 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -5,7 +5,7 @@ }, "error": { "db_url_invalid": "Database URL invalid", - "query_invalid": "SQL Query invalid", + "query_invalid": "SQL query invalid", "query_no_read_only": "SQL query must be read-only", "multiple_queries": "Multiple SQL queries are not supported", "column_invalid": "The column `{column}` is not returned by the query" @@ -15,22 +15,22 @@ "data": { "db_url": "Database URL", "name": "[%key:common::config_flow::data::name%]", - "query": "Select Query", + "query": "Select query", "column": "Column", - "unit_of_measurement": "Unit of Measure", - "value_template": "Value Template", - "device_class": "Device Class", - "state_class": "State Class" + "unit_of_measurement": "Unit of measurement", + "value_template": "Value template", + "device_class": "Device class", + "state_class": "State class" }, "data_description": { - "db_url": "Database URL, leave empty to use HA recorder database", - "name": "Name that will be used for Config Entry and also the Sensor", + "db_url": "Leave empty to use Home Assistant Recorder database", + "name": "Name that will be used for config entry and also the sensor", "query": "Query to run, needs to start with 'SELECT'", "column": "Column for returned query to present as state", - "unit_of_measurement": "Unit of Measure (optional)", - "value_template": "Value Template (optional)", + "unit_of_measurement": "The unit of measurement for the sensor (optional)", + "value_template": "Template to extract a value from the payload (optional)", "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state_class of the sensor" + "state_class": "The state class of the sensor" } } } diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index f466f3bcb62..fd641d3389d 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -105,9 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - lms.name = ( (STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME]) and status[STATUS_QUERY_LIBRARYNAME] - or host - ) - version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None + ) or host + version = (STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION]) or None # mac can be missing mac_connect = ( {(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))} @@ -128,12 +127,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - ) _LOGGER.debug("LMS Device %s", device) - server_coordinator = LMSStatusDataUpdateCoordinator(hass, lms) + server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms) - entry.runtime_data = SqueezeboxData( - coordinator=server_coordinator, - server=lms, - ) + entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) # set up player discovery known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) @@ -152,7 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - else: _LOGGER.debug("Adding new entity: %s", player) player_coordinator = SqueezeBoxPlayerUpdateCoordinator( - hass, player, lms.uuid + hass, entry, player, lms.uuid ) known_players.append(player.player_id) async_dispatcher_send( diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index ec0bac0fe43..daae8703597 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SqueezeboxConfigEntry from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN @@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: SqueezeboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Platform setup using common elements.""" diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 331bf383c70..82fa55c7b2f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +from dataclasses import dataclass, field from typing import Any from pysqueezebox import Player @@ -18,6 +19,8 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request +from .const import UNPLAYABLE_TYPES + LIBRARY = [ "Favorites", "Artists", @@ -26,9 +29,12 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Album Artists", + "Apps", + "Radios", ] -MEDIA_TYPE_TO_SQUEEZEBOX = { +MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Favorites": "favorites", "Artists": "artists", "Albums": "albums", @@ -36,38 +42,51 @@ MEDIA_TYPE_TO_SQUEEZEBOX = { "Playlists": "playlists", "Genres": "genres", "New Music": "new music", + "Album Artists": "album artists", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", MediaType.PLAYLIST: "playlist", MediaType.GENRE: "genre", + "Apps": "apps", + "Radios": "radios", } -SQUEEZEBOX_ID_BY_TYPE = { +SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.ALBUM: "album_id", MediaType.ARTIST: "artist_id", MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", "Favorites": "item_id", + MediaType.APPS: "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, + MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + MediaType.APPS: {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, } -CONTENT_TYPE_TO_CHILD_TYPE = { +CONTENT_TYPE_TO_CHILD_TYPE: dict[ + str | MediaType, + str | MediaType | None, +] = { MediaType.ALBUM: MediaType.TRACK, MediaType.PLAYLIST: MediaType.PLAYLIST, MediaType.ARTIST: MediaType.ALBUM, @@ -78,14 +97,154 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "Playlists": MediaType.PLAYLIST, "Genres": MediaType.GENRE, "Favorites": None, # can only be determined after inspecting the item + "Apps": MediaClass.APP, + "Radios": MediaClass.APP, + "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + "Album Artists": MediaType.ARTIST, + MediaType.APPS: MediaType.APP, + MediaType.APP: MediaType.TRACK, } -BROWSE_LIMIT = 1000 + +@dataclass +class BrowseData: + """Class for browser to squeezebox mappings and other browse data.""" + + content_type_to_child_type: dict[ + str | MediaType, + str | MediaType | None, + ] = field(default_factory=dict) + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + field(default_factory=dict) + ) + squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) + media_type_to_squeezebox: dict[str | MediaType, str] = field(default_factory=dict) + known_apps_radios: set[str] = field(default_factory=set) + + def __post_init__(self) -> None: + """Initialise the maps.""" + self.content_type_media_class.update(CONTENT_TYPE_MEDIA_CLASS) + self.content_type_to_child_type.update(CONTENT_TYPE_TO_CHILD_TYPE) + self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE) + self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) + + +@dataclass +class BrowseItemResponse: + """Class for response data for browse item functions.""" + + child_item_type: str | MediaType + child_media_class: dict[str, MediaClass | None] + can_expand: bool + can_play: bool + title: str + id: str + + +def _add_new_command_to_browse_data( + browse_data: BrowseData, cmd: str | MediaType, type: str +) -> None: + """Add items to maps for new apps or radios.""" + browse_data.media_type_to_squeezebox[cmd] = cmd + browse_data.squeezebox_id_by_type[cmd] = type + browse_data.content_type_media_class[cmd] = { + "item": MediaClass.DIRECTORY, + "children": MediaClass.TRACK, + } + browse_data.content_type_to_child_type[cmd] = MediaType.TRACK + + +def _build_response_apps_radios_category( + browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] +) -> BrowseItemResponse: + """Build item for App or radio category.""" + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type=cmd, + child_media_class=browse_data.content_type_media_class[cmd], + can_expand=True, + can_play=False, + ) + + +def _build_response_known_app( + browse_data: BrowseData, search_type: str, item: dict[str, Any] +) -> BrowseItemResponse: + """Build item for app or radio.""" + + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type=search_type, + child_media_class=browse_data.content_type_media_class[search_type], + can_play=bool(item["isaudio"] and item.get("url")), + can_expand=item["hasitems"], + ) + + +def _build_response_favorites(item: dict[str, Any]) -> BrowseItemResponse: + """Build item for Favorites.""" + if "album_id" in item: + return BrowseItemResponse( + id=str(item["album_id"]), + title=item["title"], + child_item_type=MediaType.ALBUM, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM], + can_expand=True, + can_play=True, + ) + if item["hasitems"] and not item["isaudio"]: + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"], + can_expand=True, + can_play=False, + ) + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK], + can_expand=item["hasitems"], + can_play=bool(item["isaudio"] and item.get("url")), + ) + + +def _get_item_thumbnail( + item: dict[str, Any], + player: Player, + entity: MediaPlayerEntity, + item_type: str | MediaType | None, + search_type: str, + internal_request: bool, +) -> str | None: + """Construct path to thumbnail image.""" + item_thumbnail: str | None = None + if artwork_track_id := item.get("artwork_track_id"): + if internal_request: + item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + elif item_type is not None: + item_thumbnail = entity.get_browse_image_url( + item_type, item.get("id", ""), artwork_track_id + ) + + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) + if item_thumbnail is None: + item_thumbnail = item.get("image_url") # will not be proxied by HA + return item_thumbnail async def build_item_response( - entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None] + entity: MediaPlayerEntity, + player: Player, + payload: dict[str, str | None], + browse_limit: int, + browse_data: BrowseData, ) -> BrowseMedia: """Create response payload for search described by payload.""" @@ -96,78 +255,93 @@ async def build_item_response( assert ( search_type is not None ) # async_browse_media will not call this function if search_type is None - media_class = CONTENT_TYPE_MEDIA_CLASS[search_type] + media_class = browse_data.content_type_media_class[search_type] children = None if search_id and search_id != search_type: - browse_id = (SQUEEZEBOX_ID_BY_TYPE[search_type], search_id) + browse_id = (browse_data.squeezebox_id_by_type[search_type], search_id) else: browse_id = None result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[search_type], - limit=BROWSE_LIMIT, + browse_data.media_type_to_squeezebox[search_type], + limit=browse_limit, browse_id=browse_id, ) if result is not None and result.get("items"): - item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] + item_type = browse_data.content_type_to_child_type[search_type] children = [] list_playable = [] for item in result["items"]: - item_id = str(item["id"]) item_thumbnail: str | None = None - if item_type: - child_item_type: MediaType | str = item_type - child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] - can_expand = child_media_class["children"] is not None - can_play = True if search_type == "Favorites": - if "album_id" in item: - item_id = str(item["album_id"]) - child_item_type = MediaType.ALBUM - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] - can_expand = True - can_play = True - elif item["hasitems"] and not item["isaudio"]: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] - can_expand = True - can_play = False - else: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = item["hasitems"] - can_play = item["isaudio"] and item.get("url") + browse_item_response = _build_response_favorites(item) - if artwork_track_id := item.get("artwork_track_id"): - if internal_request: - item_thumbnail = player.generate_image_url_from_track_id( - artwork_track_id - ) - elif item_type is not None: - item_thumbnail = entity.get_browse_image_url( - item_type, item_id, artwork_track_id - ) - else: - item_thumbnail = item.get("image_url") # will not be proxied by HA + elif search_type in ["Apps", "Radios"]: + # item["cmd"] contains the name of the command to use with the cli for the app + # add the command to the dictionaries + if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: + # Skip searches in apps as they'd need UI or if the link isn't to audio + continue + app_cmd = "app-" + item["cmd"] - assert child_media_class["item"] is not None + if app_cmd not in browse_data.known_apps_radios: + browse_data.known_apps_radios.add(app_cmd) + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + + browse_item_response = _build_response_apps_radios_category( + browse_data=browse_data, cmd=app_cmd, item=item + ) + + elif search_type in browse_data.known_apps_radios: + if ( + item.get("title") in ["Search", None] + or item.get("type") in UNPLAYABLE_TYPES + ): + # Skip searches in apps as they'd need UI + continue + + browse_item_response = _build_response_known_app( + browse_data, search_type, item + ) + + elif item_type: + browse_item_response = BrowseItemResponse( + id=str(item.get("id", "")), + title=item["title"], + child_item_type=item_type, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[item_type], + can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] + is not None, + can_play=True, + ) + + item_thumbnail = _get_item_thumbnail( + item=item, + player=player, + entity=entity, + item_type=item_type, + search_type=search_type, + internal_request=internal_request, + ) + + assert browse_item_response.child_media_class["item"] is not None children.append( BrowseMedia( - title=item["title"], - media_class=child_media_class["item"], - media_content_id=item_id, - media_content_type=child_item_type, - can_play=can_play, - can_expand=can_expand, + title=browse_item_response.title, + media_class=browse_item_response.child_media_class["item"], + media_content_id=browse_item_response.id, + media_content_type=browse_item_response.child_item_type, + can_play=browse_item_response.can_play, + can_expand=browse_item_response.can_expand, thumbnail=item_thumbnail, ) ) - list_playable.append(can_play) + list_playable.append(browse_item_response.can_play) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") @@ -175,6 +349,7 @@ async def build_item_response( assert media_class["item"] is not None if not search_id: search_id = search_type + return BrowseMedia( title=result.get("title"), media_class=media_class["item"], @@ -187,7 +362,11 @@ async def build_item_response( ) -async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: +async def library_payload( + hass: HomeAssistant, + player: Player, + browse_media: BrowseData, +) -> BrowseMedia: """Create response payload to describe contents of library.""" library_info: dict[str, Any] = { "title": "Music Library", @@ -200,10 +379,10 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: } for item in LIBRARY: - media_class = CONTENT_TYPE_MEDIA_CLASS[item] + media_class = browse_media.content_type_media_class[item] result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[item], + browse_media.media_type_to_squeezebox[item], limit=1, ) if result is not None and result.get("items") is not None: @@ -214,7 +393,7 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=item != "Favorites", + can_play=item not in ["Favorites", "Apps", "Radios"], can_expand=True, ) ) @@ -237,17 +416,27 @@ def media_source_content_filter(item: BrowseMedia) -> bool: return item.media_content_type.startswith("audio/") -async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None: +async def generate_playlist( + player: Player, + payload: dict[str, str], + browse_limit: int, + browse_media: BrowseData, +) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] - if media_type not in SQUEEZEBOX_ID_BY_TYPE: + if media_type not in browse_media.squeezebox_id_by_type: raise BrowseError(f"Media type not supported: {media_type}") - browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) + browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id) + if media_type.startswith("app-"): + category = media_type + else: + category = "titles" + result = await player.async_browse( - "titles", limit=BROWSE_LIMIT, browse_id=browse_id + category, limit=browse_limit, browse_id=browse_id ) if result and "items" in result: items: list = result["items"] diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index c372c7262d4..2853ad14217 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -10,16 +10,35 @@ from typing import TYPE_CHECKING, Any from pysqueezebox import Server, async_discover import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_BROWSE_LIMIT, + CONF_HTTPS, + CONF_VOLUME_STEP, + DEFAULT_BROWSE_LIMIT, + DEFAULT_PORT, + DEFAULT_VOLUME_STEP, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): self.data_schema = _base_schema() self.discovery_info: dict[str, Any] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + async def _discover(self, uuid: str | None = None) -> None: """Discover an unconfigured LMS server.""" self.discovery_info = None @@ -200,7 +225,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_edit() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery of a Squeezebox player.""" _LOGGER.debug( @@ -222,3 +247,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): # if the player is unknown, then we likely need to configure its server return await self.async_step_user() + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_BROWSE_LIMIT): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX) + ), + vol.Coerce(int), + ), + vol.Required(CONF_VOLUME_STEP): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER) + ), + vol.Coerce(int), + ), + } +) + + +class OptionsFlowHandler(OptionsFlow): + """Options Flow Handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Options Flow Steps.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, + { + CONF_BROWSE_LIMIT: self.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ), + CONF_VOLUME_STEP: self.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ), + }, + ), + ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 8bc33214170..5ce95d25632 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -27,8 +27,20 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" -SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +SQUEEZEBOX_SOURCE_STRINGS = ( + "source:", + "wavin:", + "spotify:", + "loop:", +) SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" DISCOVERY_INTERVAL = 60 PLAYER_UPDATE_INTERVAL = 5 +CONF_BROWSE_LIMIT = "browse_limit" +CONF_VOLUME_STEP = "volume_step" +DEFAULT_BROWSE_LIMIT = 1000 +DEFAULT_VOLUME_STEP = 5 +ATTR_ANNOUNCE_VOLUME = "announce_volume" +ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" +UNPLAYABLE_TYPES = ("text", "actions") diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index f3aacbc9833..955e2896947 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -1,11 +1,13 @@ """DataUpdateCoordinator for the Squeezebox integration.""" +from __future__ import annotations + from asyncio import timeout from collections.abc import Callable from datetime import timedelta import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any from pysqueezebox import Player, Server @@ -14,6 +16,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from . import SqueezeboxConfigEntry + from .const import ( PLAYER_UPDATE_INTERVAL, SENSOR_UPDATE_INTERVAL, @@ -30,11 +35,16 @@ _LOGGER = logging.getLogger(__name__) class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): """LMS Status custom coordinator.""" - def __init__(self, hass: HomeAssistant, lms: Server) -> None: + config_entry: SqueezeboxConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: SqueezeboxConfigEntry, lms: Server + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=lms.name, update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), always_update=False, @@ -80,11 +90,20 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for Squeezebox players.""" - def __init__(self, hass: HomeAssistant, player: Player, server_uuid: str) -> None: + config_entry: SqueezeboxConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SqueezeboxConfigEntry, + player: Player, + server_uuid: str, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=player.name, update_interval=timedelta(seconds=PLAYER_UPDATE_INTERVAL), always_update=True, diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 09eaa4026f4..e9b89291749 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.11.1"] + "requirements": ["pysqueezebox==0.12.0"] } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 19cd1e36910..1767d92730a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_EXTRA, BrowseError, BrowseMedia, MediaPlayerEnqueue, @@ -40,18 +41,25 @@ from homeassistant.helpers.device_registry import ( format_mac, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( + BrowseData, build_item_response, generate_playlist, library_payload, media_source_content_filter, ) from .const import ( + ATTR_ANNOUNCE_TIMEOUT, + ATTR_ANNOUNCE_VOLUME, + CONF_BROWSE_LIMIT, + CONF_VOLUME_STEP, + DEFAULT_BROWSE_LIMIT, + DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -113,7 +121,7 @@ async def start_server_discovery(hass: HomeAssistant) -> None: async def async_setup_entry( hass: HomeAssistant, entry: SqueezeboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Squeezebox media_player platform from a server config entry.""" @@ -153,6 +161,26 @@ async def async_setup_entry( entry.async_on_unload(async_at_start(hass, start_server_discovery)) +def get_announce_volume(extra: dict) -> float | None: + """Get announce volume from extra service data.""" + if ATTR_ANNOUNCE_VOLUME not in extra: + return None + announce_volume = float(extra[ATTR_ANNOUNCE_VOLUME]) + if not (0 < announce_volume <= 1): + raise ValueError + return announce_volume * 100 + + +def get_announce_timeout(extra: dict) -> int | None: + """Get announce volume from extra service data.""" + if ATTR_ANNOUNCE_TIMEOUT not in extra: + return None + announce_timeout = int(extra[ATTR_ANNOUNCE_TIMEOUT]) + if announce_timeout < 1: + raise ValueError + return announce_timeout + + class SqueezeBoxMediaPlayerEntity( CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity ): @@ -166,6 +194,7 @@ class SqueezeBoxMediaPlayerEntity( | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SEEK @@ -179,15 +208,13 @@ class SqueezeBoxMediaPlayerEntity( | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE ) _attr_has_entity_name = True _attr_name = None _last_update: datetime | None = None - def __init__( - self, - coordinator: SqueezeBoxPlayerUpdateCoordinator, - ) -> None: + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: """Initialize the SqueezeBox device.""" super().__init__(coordinator) player = coordinator.player @@ -197,7 +224,7 @@ class SqueezeBoxMediaPlayerEntity( self._previous_media_position = 0 self._attr_unique_id = format_mac(player.player_id) _manufacturer = None - if player.model == "SqueezeLite" or "SqueezePlay" in player.model: + if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: _manufacturer = "Ralph Irving" elif ( "Squeezebox" in player.model @@ -214,6 +241,7 @@ class SqueezeBoxMediaPlayerEntity( model=player.model, manufacturer=_manufacturer, ) + self._browse_data = BrowseData() @callback def _handle_coordinator_update(self) -> None: @@ -223,6 +251,23 @@ class SqueezeBoxMediaPlayerEntity( self._last_update = utcnow() self.async_write_ha_state() + @property + def volume_step(self) -> float: + """Return the step to be used for volume up down.""" + return float( + self.coordinator.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ) + / 100 + ) + + @property + def browse_limit(self) -> int: + """Return the step to be used for volume up down.""" + return self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) + @property def available(self) -> bool: """Return True if entity is available.""" @@ -366,16 +411,6 @@ class SqueezeBoxMediaPlayerEntity( await self._player.async_set_power(False) await self.coordinator.async_refresh() - async def async_volume_up(self) -> None: - """Volume up media player.""" - await self._player.async_set_volume("+5") - await self.coordinator.async_refresh() - - async def async_volume_down(self) -> None: - """Volume down media player.""" - await self._player.async_set_volume("-5") - await self.coordinator.async_refresh() - async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" volume_percent = str(int(volume * 100)) @@ -428,7 +463,11 @@ class SqueezeBoxMediaPlayerEntity( await self.coordinator.async_refresh() async def async_play_media( - self, media_type: MediaType | str, media_id: str, **kwargs: Any + self, + media_type: MediaType | str, + media_id: str, + announce: bool | None = None, + **kwargs: Any, ) -> None: """Send the play_media command to the media player.""" index = None @@ -451,6 +490,32 @@ class SqueezeBoxMediaPlayerEntity( ) media_id = play_item.url + if announce: + if media_type not in MediaType.MUSIC: + raise ServiceValidationError( + "Announcements must have media type of 'music'. Playlists are not supported" + ) + + extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) + cmd = "announce" + try: + announce_volume = get_announce_volume(extra) + except ValueError: + raise ServiceValidationError( + f"{ATTR_ANNOUNCE_VOLUME} must be a number greater than 0 and less than or equal to 1" + ) from None + else: + self._player.set_announce_volume(announce_volume) + + try: + announce_timeout = get_announce_timeout(extra) + except ValueError: + raise ServiceValidationError( + f"{ATTR_ANNOUNCE_TIMEOUT} must be a whole number greater than 0" + ) from None + else: + self._player.set_announce_timeout(announce_timeout) + if media_type in MediaType.MUSIC: if not media_id.startswith(SQUEEZEBOX_SOURCE_STRINGS): # do not process special squeezebox "source" media ids @@ -466,7 +531,9 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_id, "search_type": MediaType.PLAYLIST, } - playlist = await generate_playlist(self._player, payload) + playlist = await generate_playlist( + self._player, payload, self.browse_limit, self._browse_data + ) except BrowseError: # a list of urls content = json.loads(media_id) @@ -477,7 +544,9 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_id, "search_type": media_type, } - playlist = await generate_playlist(self._player, payload) + playlist = await generate_playlist( + self._player, payload, self.browse_limit, self._browse_data + ) _LOGGER.debug("Generated playlist: %s", playlist) @@ -575,7 +644,7 @@ class SqueezeBoxMediaPlayerEntity( ) if media_content_type in [None, "library"]: - return await library_payload(self.hass, self._player) + return await library_payload(self.hass, self._player, self._browse_data) if media_content_id and media_source.is_media_source_id(media_content_id): return await media_source.async_browse_media( @@ -587,7 +656,13 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_content_id, } - return await build_item_response(self, self._player, payload) + return await build_item_response( + self, + self._player, + payload, + self.browse_limit, + self._browse_data, + ) async def async_get_browse_image( self, diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 0ca33179f9f..c0a7a37d539 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import SqueezeboxConfigEntry @@ -73,7 +73,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: SqueezeboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Platform setup using common elements.""" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index bce71ddb5f2..ed569989b56 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -103,5 +103,20 @@ "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } } + }, + "options": { + "step": { + "init": { + "title": "LMS Configuration", + "data": { + "browse_limit": "Browse limit", + "volume_step": "Volume step" + }, + "data_description": { + "browse_limit": "Maximum number of items when browsing or in a playlist.", + "volume_step": "Amount to adjust the volume when turning volume up or down." + } + } + } } } diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index 591ba5043e9..13c21709445 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import CONF_IS_TOU, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER from .coordinator import SRPEnergyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -26,9 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_password, ) - coordinator = SRPEnergyDataUpdateCoordinator( - hass, api_instance, entry.data[CONF_IS_TOU] - ) + coordinator = SRPEnergyDataUpdateCoordinator(hass, entry, api_instance) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index e5a72457433..f3821891afa 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -12,7 +12,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES, PHOENIX_TIME_ZONE +from .const import ( + CONF_IS_TOU, + DOMAIN, + LOGGER, + MIN_TIME_BETWEEN_UPDATES, + PHOENIX_TIME_ZONE, +) TIMEOUT = 10 PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) @@ -24,14 +30,15 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): config_entry: ConfigEntry def __init__( - self, hass: HomeAssistant, client: SrpEnergyClient, is_time_of_use: bool + self, hass: HomeAssistant, config_entry: ConfigEntry, client: SrpEnergyClient ) -> None: """Initialize the srp_energy data coordinator.""" self._client = client - self._is_time_of_use = is_time_of_use + self._is_time_of_use = config_entry.data[CONF_IS_TOU] super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=MIN_TIME_BETWEEN_UPDATES, ) diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index a9f5c25d6a5..89274390411 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -20,7 +20,9 @@ from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SRP Energy Usage sensor.""" coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index ccd69961975..c5fb349ddbb 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Mapping -from dataclasses import dataclass, field from datetime import timedelta from enum import Enum from functools import partial @@ -44,13 +43,36 @@ from homeassistant.const import ( __version__ as current_version, ) from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_callback -from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT as _ATTR_NT, + ATTR_ST as _ATTR_ST, + ATTR_UPNP_DEVICE_TYPE as _ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME as _ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER as _ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL as _ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION as _ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME as _ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER as _ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL as _ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL as _ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL as _ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST as _ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN as _ATTR_UPNP_UDN, + ATTR_UPNP_UPC as _ATTR_UPNP_UPC, + SsdpServiceInfo as _SsdpServiceInfo, +) from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass @@ -77,30 +99,90 @@ ATTR_SSDP_SERVER = "ssdp_server" ATTR_SSDP_BOOTID = "BOOTID.UPNP.ORG" ATTR_SSDP_NEXTBOOTID = "NEXTBOOTID.UPNP.ORG" # Attributes for accessing info from retrieved UPnP device description -ATTR_ST = "st" -ATTR_NT = "nt" -ATTR_UPNP_DEVICE_TYPE = "deviceType" -ATTR_UPNP_FRIENDLY_NAME = "friendlyName" -ATTR_UPNP_MANUFACTURER = "manufacturer" -ATTR_UPNP_MANUFACTURER_URL = "manufacturerURL" -ATTR_UPNP_MODEL_DESCRIPTION = "modelDescription" -ATTR_UPNP_MODEL_NAME = "modelName" -ATTR_UPNP_MODEL_NUMBER = "modelNumber" -ATTR_UPNP_MODEL_URL = "modelURL" -ATTR_UPNP_SERIAL = "serialNumber" -ATTR_UPNP_SERVICE_LIST = "serviceList" -ATTR_UPNP_UDN = "UDN" -ATTR_UPNP_UPC = "UPC" -ATTR_UPNP_PRESENTATION_URL = "presentationURL" +_DEPRECATED_ATTR_ST = DeprecatedConstant( + _ATTR_ST, + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + "2026.2", +) +_DEPRECATED_ATTR_NT = DeprecatedConstant( + _ATTR_NT, + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_DEVICE_TYPE = DeprecatedConstant( + _ATTR_UPNP_DEVICE_TYPE, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_FRIENDLY_NAME = DeprecatedConstant( + _ATTR_UPNP_FRIENDLY_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MANUFACTURER_URL = DeprecatedConstant( + _ATTR_UPNP_MANUFACTURER_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_DESCRIPTION = DeprecatedConstant( + _ATTR_UPNP_MODEL_DESCRIPTION, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NAME = DeprecatedConstant( + _ATTR_UPNP_MODEL_NAME, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_NUMBER = DeprecatedConstant( + _ATTR_UPNP_MODEL_NUMBER, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_MODEL_URL = DeprecatedConstant( + _ATTR_UPNP_MODEL_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERIAL = DeprecatedConstant( + _ATTR_UPNP_SERIAL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_SERVICE_LIST = DeprecatedConstant( + _ATTR_UPNP_SERVICE_LIST, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UDN = DeprecatedConstant( + _ATTR_UPNP_UDN, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_UPC = DeprecatedConstant( + _ATTR_UPNP_UPC, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + "2026.2", +) +_DEPRECATED_ATTR_UPNP_PRESENTATION_URL = DeprecatedConstant( + _ATTR_UPNP_PRESENTATION_URL, + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + "2026.2", +) # Attributes for accessing info added by Home Assistant ATTR_HA_MATCHING_DOMAINS = "x_homeassistant_matching_domains" PRIMARY_MATCH_KEYS = [ - ATTR_UPNP_MANUFACTURER, - ATTR_ST, - ATTR_UPNP_DEVICE_TYPE, - ATTR_NT, - ATTR_UPNP_MANUFACTURER_URL, + _ATTR_UPNP_MANUFACTURER, + _ATTR_ST, + _ATTR_UPNP_DEVICE_TYPE, + _ATTR_NT, + _ATTR_UPNP_MANUFACTURER_URL, ] _LOGGER = logging.getLogger(__name__) @@ -108,27 +190,16 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) - -@dataclass(slots=True) -class SsdpServiceInfo(BaseServiceInfo): - """Prepared info from ssdp/upnp entries.""" - - ssdp_usn: str - ssdp_st: str - upnp: Mapping[str, Any] - ssdp_location: str | None = None - ssdp_nt: str | None = None - ssdp_udn: str | None = None - ssdp_ext: str | None = None - ssdp_server: str | None = None - ssdp_headers: Mapping[str, Any] = field(default_factory=dict) - ssdp_all_locations: set[str] = field(default_factory=set) - x_homeassistant_matching_domains: set[str] = field(default_factory=set) +_DEPRECATED_SsdpServiceInfo = DeprecatedConstant( + _SsdpServiceInfo, + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + "2026.2", +) SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") type SsdpHassJobCallback = HassJob[ - [SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None ] SSDP_SOURCE_SSDP_CHANGE_MAPPING: Mapping[SsdpSource, SsdpChange] = { @@ -148,7 +219,9 @@ def _format_err(name: str, *args: Any) -> str: @bind_hass async def async_register_callback( hass: HomeAssistant, - callback: Callable[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None], + callback: Callable[ + [_SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None + ], match_dict: dict[str, str] | None = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -169,7 +242,7 @@ async def async_register_callback( @bind_hass async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str -) -> SsdpServiceInfo | None: +) -> _SsdpServiceInfo | None: """Fetch the discovery info cache.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn_st(udn, st) @@ -178,7 +251,7 @@ async def async_get_discovery_info_by_udn_st( @bind_hass async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the st.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_st(st) @@ -187,7 +260,7 @@ async def async_get_discovery_info_by_st( @bind_hass async def async_get_discovery_info_by_udn( hass: HomeAssistant, udn: str -) -> list[SsdpServiceInfo]: +) -> list[_SsdpServiceInfo]: """Fetch all the entries matching the udn.""" scanner: Scanner = hass.data[DOMAIN][SSDP_SCANNER] return await scanner.async_get_discovery_info_by_udn(udn) @@ -200,7 +273,7 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A for source_ip in await network.async_get_enabled_source_ips(hass) if not source_ip.is_loopback and not source_ip.is_global - and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4) + and ((source_ip.version == 6 and source_ip.scope_id) or source_ip.version == 4) } @@ -227,7 +300,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def _async_process_callbacks( hass: HomeAssistant, callbacks: list[SsdpHassJobCallback], - discovery_info: SsdpServiceInfo, + discovery_info: _SsdpServiceInfo, ssdp_change: SsdpChange, ) -> None: for callback in callbacks: @@ -562,11 +635,11 @@ class Scanner: ) def _async_dismiss_discoveries( - self, byebye_discovery_info: SsdpServiceInfo + self, byebye_discovery_info: _SsdpServiceInfo ) -> None: """Dismiss all discoveries for the given address.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - SsdpServiceInfo, + _SsdpServiceInfo, lambda service_info: bool( service_info.ssdp_st == byebye_discovery_info.ssdp_st and service_info.ssdp_location == byebye_discovery_info.ssdp_location @@ -589,7 +662,7 @@ class Scanner: async def _async_headers_to_discovery_info( self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict - ) -> SsdpServiceInfo: + ) -> _SsdpServiceInfo: """Combine the headers and description into discovery_info. Building this is a bit expensive so we only do it on demand. @@ -602,7 +675,7 @@ class Scanner: async def async_get_discovery_info_by_udn_st( self, udn: str, st: str - ) -> SsdpServiceInfo | None: + ) -> _SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" for ssdp_device in self._ssdp_devices: if ssdp_device.udn == udn: @@ -612,7 +685,7 @@ class Scanner: ) return None - async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_st(self, st: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -620,7 +693,7 @@ class Scanner: if (headers := ssdp_device.combined_headers(st)) ] - async def async_get_discovery_info_by_udn(self, udn: str) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_udn(self, udn: str) -> list[_SsdpServiceInfo]: """Return matching discovery_infos for a udn.""" return [ await self._async_headers_to_discovery_info(ssdp_device, headers) @@ -665,7 +738,7 @@ def discovery_info_from_headers_and_description( ssdp_device: SsdpDevice, combined_headers: CaseInsensitiveDict, info_desc: Mapping[str, Any], -) -> SsdpServiceInfo: +) -> _SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] ssdp_st = combined_headers.get_lower("st") @@ -681,11 +754,11 @@ def discovery_info_from_headers_and_description( ssdp_st = combined_headers["nt"] # Ensure UPnP "udn" is set - if ATTR_UPNP_UDN not in upnp_info: + if _ATTR_UPNP_UDN not in upnp_info: if udn := _udn_from_usn(ssdp_usn): - upnp_info[ATTR_UPNP_UDN] = udn + upnp_info[_ATTR_UPNP_UDN] = udn - return SsdpServiceInfo( + return _SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, ssdp_ext=combined_headers.get_lower("ext"), @@ -887,3 +960,11 @@ class Server: """Stop UPnP/SSDP servers.""" for server in self._upnp_servers: await server.async_stop() + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 2632e37aa98..6e1fba8c3a3 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.42.0"] + "requirements": ["async-upnp-client==0.43.0"] } diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 4b1425ae7d9..0fb5a367148 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -180,6 +180,7 @@ class StarlineAccount: "online": device.online, } + # Deprecated and should be removed in 2025.8 @staticmethod def engine_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for engine switch.""" diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index 69f0ae06d02..a570b26a0d1 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -43,8 +43,13 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( ), BinarySensorEntityDescription( key="run", - translation_key="is_running", - device_class=BinarySensorDeviceClass.RUNNING, + translation_key="ignition", + entity_registry_enabled_default=False, + ), + BinarySensorEntityDescription( + key="r_start", + translation_key="autostart", + entity_registry_enabled_default=False, ), BinarySensorEntityDescription( key="hfree", @@ -65,7 +70,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index 6fb307cda74..fa46d2a3773 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -34,7 +34,9 @@ BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine button.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index 610317b72c3..0c8418d28fc 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -3,7 +3,7 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .account import StarlineAccount, StarlineDevice @@ -12,7 +12,9 @@ from .entity import StarlineEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up StarLine entry.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 74807996dfb..f8846c2a97f 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -27,20 +27,20 @@ class StarlineEntity(Entity): self._unsubscribe_api: Callable | None = None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._account.api.available - def update(self): + def update(self) -> None: """Read new state data.""" self.schedule_update_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() self._unsubscribe_api = self._account.api.add_update_listener(self.update) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Call when entity is being removed from Home Assistant.""" await super().async_will_remove_from_hass() if self._unsubscribe_api is not None: diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json index d7d20ae03bd..07713a0cdfe 100644 --- a/homeassistant/components/starline/icons.json +++ b/homeassistant/components/starline/icons.json @@ -13,8 +13,11 @@ "moving_ban": { "default": "mdi:car-off" }, - "is_running": { - "default": "mdi:speedometer" + "ignition": { + "default": "mdi:key-variant" + }, + "autostart": { + "default": "mdi:auto-mode" } }, "button": { diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 19aad1a19b2..43886d63962 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -15,7 +15,9 @@ from .entity import StarlineEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine lock.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index f9bd304c1e1..16988f1a9dc 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level from .account import StarlineAccount, StarlineDevice @@ -87,7 +87,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index f292a74621c..b3ce755778e 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -64,8 +64,11 @@ "moving_ban": { "name": "Moving ban" }, - "is_running": { - "name": "Running" + "ignition": { + "name": "Ignition" + }, + "autostart": { + "name": "Autostart" } }, "device_tracker": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 05193d98c8a..79d4fa86ddf 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -34,7 +34,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine switch.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] @@ -72,6 +74,7 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): def extra_state_attributes(self): """Return the state attributes of the switch.""" if self._key == "ign": + # Deprecated and should be removed in 2025.8 return self._account.engine_attrs(self._device) return None diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 282323d8b7b..063919179ac 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index 17081a7491e..0c512bb21c5 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -19,25 +17,19 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Set up Starlink from a config entry.""" - coordinator = StarlinkUpdateCoordinator( - hass=hass, - url=entry.data[CONF_IP_ADDRESS], - name=entry.title, - ) + config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry) + await config_entry.runtime_data.async_config_entry_first_refresh() - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> 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 + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index e48d28dcc44..e06e79009c3 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -10,24 +10,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: StarlinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkBinarySensorEntity(coordinator, description) + StarlinkBinarySensorEntity(config_entry.runtime_data, description) for description in BINARY_SENSORS ) @@ -65,6 +63,7 @@ BINARY_SENSORS = [ key="currently_obstructed", translation_key="currently_obstructed", device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status["currently_obstructed"], ), StarlinkBinarySensorEntityDescription( @@ -114,4 +113,9 @@ BINARY_SENSORS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_unexpected_location"], ), + StarlinkBinarySensorEntityDescription( + key="connection", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda data: data.status["state"] == "CONNECTED", + ), ] diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index f8f18763d30..15f35659b49 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -10,24 +10,23 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: StarlinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkButtonEntity(coordinator, description) for description in BUTTONS + StarlinkButtonEntity(config_entry.runtime_data, description) + for description in BUTTONS ) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 89d03a4fadc..02d51cd805e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -26,12 +26,16 @@ from starlink_grpc import ( status_data, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) +type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator] + @dataclass class StarlinkData: @@ -49,14 +53,18 @@ class StarlinkData: class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): """Coordinates updates between all Starlink sensors defined in this file.""" - def __init__(self, hass: HomeAssistant, name: str, url: str) -> None: + config_entry: StarlinkConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" - self.channel_context = ChannelContext(target=url) + self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS]) + self.history_stats_start = None self.timezone = ZoneInfo(hass.config.time_zone) super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=timedelta(seconds=5), ) @@ -67,7 +75,18 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): location = location_data(context) sleep = get_sleep_config(context) status, obstruction, alert = status_data(context) - usage, consumption = history_stats(parse_samples=-1, context=context)[-2:] + index, _, _, _, _, usage, consumption, *_ = history_stats( + parse_samples=-1, start=self.history_stats_start, context=context + ) + self.history_stats_start = index["end_counter"] + if self.data: + if index["samples"] > 0: + usage["download_usage"] += self.data.usage["download_usage"] + usage["upload_usage"] += self.data.usage["upload_usage"] + consumption["total_energy"] += self.data.consumption["total_energy"] + else: + usage = self.data.usage + consumption = self.data.consumption return StarlinkData( location, sleep, status, obstruction, alert, usage, consumption ) diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 5174be19760..dbe31947b55 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -8,23 +8,22 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_ALTITUDE, DOMAIN -from .coordinator import StarlinkData +from .const import ATTR_ALTITUDE +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: StarlinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkDeviceTrackerEntity(coordinator, description) + StarlinkDeviceTrackerEntity(config_entry.runtime_data, description) for description in DEVICE_TRACKERS ) diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py index c619458b1dd..543fe9d8dde 100644 --- a/homeassistant/components/starlink/diagnostics.py +++ b/homeassistant/components/starlink/diagnostics.py @@ -4,18 +4,15 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry TO_REDACT = {"id", "latitude", "longitude", "altitude"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, config_entry: StarlinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for Starlink config entries.""" - coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(asdict(coordinator.data), TO_REDACT) + return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 5481e310fbd..d07e8174b27 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -24,23 +23,23 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import now -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: StarlinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSensorEntity(coordinator, description) for description in SENSORS + StarlinkSensorEntity(config_entry.runtime_data, description) + for description in SENSORS ) diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 3534748127e..c6dc237643e 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -11,23 +11,22 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: StarlinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSwitchEntity(coordinator, description) for description in SWITCHES + StarlinkSwitchEntity(config_entry.runtime_data, description) + for description in SWITCHES ) diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 7395ec101ba..9f564333218 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -8,24 +8,23 @@ from datetime import UTC, datetime, time, tzinfo import math from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: StarlinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all time entities for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkTimeEntity(coordinator, description) for description in TIMES + StarlinkTimeEntity(config_entry.runtime_data, description) + for description in TIMES ) diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 5fc4872a754..62e02426fcb 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -25,8 +25,8 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 5252c23fd3d..a5c5f10ecd0 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -47,7 +47,10 @@ from homeassistant.core import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, @@ -617,7 +620,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Statistics sensor entry.""" sampling_size = entry.options.get(CONF_SAMPLES_MAX_BUFFER_SIZE) diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index 91aead261ff..e1085a016ce 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -5,8 +5,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { - "missing_max_age_or_sampling_size": "The sensor configuration must provide 'max_age' and/or 'sampling_size'", - "missing_keep_last_sample": "The sensor configuration must provide 'max_age' if 'keep_last_sample' is True" + "missing_max_age_or_sampling_size": "The sensor configuration must provide 'Max age' and/or 'Sampling size'", + "missing_keep_last_sample": "The sensor configuration must provide 'Max age' if 'Keep last sample' is true" }, "step": { "user": { @@ -21,7 +21,7 @@ } }, "state_characteristic": { - "description": "Read the documention for further details on available options and how to use them.", + "description": "Read the documentation for further details on available options and how to use them.", "data": { "state_characteristic": "Statistic characteristic" }, @@ -30,7 +30,7 @@ } }, "options": { - "description": "Read the documention for further details on how to configure the statistics sensor using these options.", + "description": "Read the documentation for further details on how to configure the statistics sensor using these options.", "data": { "sampling_size": "Sampling size", "max_age": "Max age", @@ -41,8 +41,8 @@ "data_description": { "sampling_size": "Maximum number of source sensor measurements stored.", "max_age": "Maximum age of source sensor measurements stored.", - "keep_last_sample": "Defines whether the most recent sampled value should be preserved regardless of the 'max age' setting.", - "percentile": "Only relevant in combination with the 'percentile' characteristic. Must be a value between 1 and 99.", + "keep_last_sample": "Defines whether the most recent sampled value should be preserved regardless of the 'Max age' setting.", + "percentile": "Only relevant in combination with the 'Percentile' characteristic. Must be a value between 1 and 99.", "precision": "Defines the number of decimal places of the calculated sensor value." } } diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py index 50b74b20028..4e8e5b7f942 100644 --- a/homeassistant/components/statsd/__init__.py +++ b/homeassistant/components/statsd/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 6e45758fb94..7a2c32cb4d5 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -2,19 +2,17 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import SteamDataUpdateCoordinator +from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Set up Steam from a config entry.""" - coordinator = SteamDataUpdateCoordinator(hass) + coordinator = SteamDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 69009fca8c4..57c75f0a704 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -18,8 +18,8 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from . import SteamConfigEntry from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS +from .coordinator import SteamConfigEntry # To avoid too long request URIs, the amount of ids to request is limited MAX_IDS_TO_REQUEST = 275 diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 81a3bb0d898..731154183ed 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING import steam from steam.api import _interface_method as INTMethod +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -15,8 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ACCOUNTS, DOMAIN, LOGGER -if TYPE_CHECKING: - from . import SteamConfigEntry +type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] class SteamDataUpdateCoordinator( @@ -26,11 +25,12 @@ class SteamDataUpdateCoordinator( config_entry: SteamConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SteamConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 058bb386383..c1e20933185 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -8,11 +8,10 @@ from typing import cast from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp -from . import SteamConfigEntry from .const import ( CONF_ACCOUNTS, STEAM_API_URL, @@ -21,7 +20,7 @@ from .const import ( STEAM_MAIN_IMAGE_FILE, STEAM_STATUSES, ) -from .coordinator import SteamDataUpdateCoordinator +from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator from .entity import SteamEntity PARALLEL_UPDATES = 1 @@ -30,7 +29,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: SteamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Steam platform.""" async_add_entities( diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 8d8401ec6fd..380f25ea8da 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -8,7 +8,7 @@ from typing import Any from aiosteamist import Steamist from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -52,9 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] coordinator = SteamistDataUpdateCoordinator( hass, + entry, Steamist(host, async_get_clientsession(hass)), - host, - entry.data.get(CONF_NAME), # Only found from discovery ) await coordinator.async_config_entry_first_refresh() if not async_get_discovery(hass, host): diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index f22eafc6afd..cadcba118a1 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -9,12 +9,12 @@ from aiosteamist import Steamist from discovery30303 import Device30303, normalize_mac import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONNECTION_EXCEPTIONS, DISCOVER_SCAN_TIMEOUT, DOMAIN @@ -41,7 +41,7 @@ class SteamistConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Device30303 | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = Device30303( diff --git a/homeassistant/components/steamist/coordinator.py b/homeassistant/components/steamist/coordinator.py index c5aa7be7ddc..3f864364be7 100644 --- a/homeassistant/components/steamist/coordinator.py +++ b/homeassistant/components/steamist/coordinator.py @@ -7,6 +7,8 @@ import logging from aiosteamist import Steamist, SteamistStatus +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,20 +18,22 @@ _LOGGER = logging.getLogger(__name__) class SteamistDataUpdateCoordinator(DataUpdateCoordinator[SteamistStatus]): """DataUpdateCoordinator to gather data from a steamist steam shower.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: Steamist, - host: str, - device_name: str | None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific steamist.""" self.client = client - self.device_name = device_name + self.device_name = config_entry.data.get(CONF_NAME) # Only found from discovery super().__init__( hass, _LOGGER, - name=f"Steamist {host}", + config_entry=config_entry, + name=f"Steamist {config_entry.data[CONF_HOST]}", update_interval=timedelta(seconds=5), always_update=False, ) diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index b15d7f87312..ab81c8b5a53 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -16,5 +16,5 @@ "documentation": "https://www.home-assistant.io/integrations/steamist", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], - "requirements": ["aiosteamist==1.0.0", "discovery30303==0.3.2"] + "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.3"] } diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index 7c24d015513..94e3ff86ee1 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SteamistDataUpdateCoordinator @@ -58,7 +58,7 @@ SENSORS: tuple[SteamistSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py index 91806f4fa0c..17e1d6d47ac 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SteamistDataUpdateCoordinator @@ -22,7 +22,7 @@ ACTIVE_SWITCH = SwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index a5e92312f3d..94a3bd1058b 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -3,13 +3,13 @@ from datetime import timedelta import logging +from pymodbus.client import ModbusTcpClient from pystiebeleltron import pystiebeleltron import voluptuous as vol from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -55,13 +55,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class StiebelEltronData: """Get the latest data and update the states.""" - def __init__(self, name, modbus_client): + def __init__(self, name: str, modbus_client: ModbusTcpClient) -> None: """Init the STIEBEL ELTRON data object.""" self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1) @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Update unit data.""" if not self.api.update(): _LOGGER.warning("Modbus read failed") diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 676f613f382..4d302a0f70d 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as STE_DOMAIN +from . import DOMAIN as STE_DOMAIN, StiebelEltronData DEPENDENCIES = ["stiebel_eltron"] @@ -81,15 +81,15 @@ class StiebelEltron(ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, name, ste_data): + def __init__(self, name: str, ste_data: StiebelEltronData) -> None: """Initialize the unit.""" self._name = name - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._operation = None - self._filter_alarm = None - self._force_update = False + self._target_temperature: float | int | None = None + self._current_temperature: float | int | None = None + self._current_humidity: float | int | None = None + self._operation: str | None = None + self._filter_alarm: bool | None = None + self._force_update: bool = False self._ste_data = ste_data def update(self) -> None: @@ -108,59 +108,59 @@ class StiebelEltron(ClimateEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool | None]: """Return device specific state attributes.""" return {"filter_alarm": self._filter_alarm} @property - def name(self): + def name(self) -> str: """Return the name of the climate device.""" return self._name # Handle ClimateEntityFeature.TARGET_TEMPERATURE @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.1 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return 10.0 @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return 30.0 @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" return float(f"{self._current_humidity:.1f}") @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return STE_TO_HA_HVAC.get(self._operation) + return STE_TO_HA_HVAC.get(self._operation) if self._operation else None @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return STE_TO_HA_PRESET.get(self._operation) + return STE_TO_HA_PRESET.get(self._operation) if self._operation else None @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" return SUPPORT_PRESET diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..9adfc09de0e 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,13 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), + xy = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not latitude or not longitude: + if not xy: ir.async_create_issue( hass, DOMAIN, @@ -67,8 +65,8 @@ async def async_migrate_entry( entry, version=2, data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, + CONF_LATITUDE: xy["x"], + CONF_LONGITUDE: xy["y"], }, ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..ff14bce26e6 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,15 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), + xy = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if latitude and longitude: + if xy: return self.async_create_entry( title="Stookwijzer", - data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]}, ) errors["base"] = "unknown" diff --git a/homeassistant/components/stookwijzer/coordinator.py b/homeassistant/components/stookwijzer/coordinator.py index 23092bed66e..8f81494b7d5 100644 --- a/homeassistant/components/stookwijzer/coordinator.py +++ b/homeassistant/components/stookwijzer/coordinator.py @@ -20,18 +20,23 @@ type StookwijzerConfigEntry = ConfigEntry[StookwijzerCoordinator] class StookwijzerCoordinator(DataUpdateCoordinator[None]): """Stookwijzer update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: StookwijzerConfigEntry) -> None: + config_entry: StookwijzerConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: StookwijzerConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) self.client = Stookwijzer( async_get_clientsession(hass), - entry.data[CONF_LATITUDE], - entry.data[CONF_LONGITUDE], + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], ) async def _async_update_data(self) -> None: diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 3fe16fb3d33..dd10f57f485 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.1"] + "requirements": ["stookwijzer==1.6.1"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 2660ff2ddb2..91224b711be 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -59,7 +59,7 @@ STOOKWIJZER_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: StookwijzerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Stookwijzer sensor from a config entry.""" async_add_entities( diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 189af89b282..d7304fa1238 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Select the location you want to recieve the Stookwijzer information for.", + "description": "Select the location you want to receive the Stookwijzer information for.", "data": { "location": "[%key:common::config_flow::data::location%]" }, diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 8692a2acaad..8fa4c69ac5a 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -33,7 +33,7 @@ from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.async_ import create_eager_task @@ -90,11 +90,11 @@ __all__ = [ "OUTPUT_FORMATS", "RTSP_TRANSPORTS", "SOURCE_TIMEOUT", + "Orientation", "Stream", "StreamClientError", "StreamOpenClientError", "create_stream", - "Orientation", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 4184b23b9a0..b804055a740 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -166,7 +166,7 @@ class Segment: self.hls_playlist_parts.append( f"#EXT-X-PART:DURATION={part.duration:.3f},URI=" f'"./segment/{self.sequence}.{part_num}.m4s"' - f'{",INDEPENDENT=YES" if part.has_keyframe else ""}' + f"{',INDEPENDENT=YES' if part.has_keyframe else ''}" ) if self.complete: # Construct the final playlist_template. The placeholder will share a diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 16694822b01..32845840f38 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -188,9 +188,13 @@ class HlsPlaylistView(StreamView): if track.stream_settings.ll_hls: playlist.extend( [ - f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES", + "#EXT-X-PART-INF:PART-TARGET=" + f"{track.stream_settings.part_target_duration:.3f}", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * track.stream_settings.part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * track.stream_settings.part_target_duration:.3f}" + ",PRECISE=YES", ] ) else: @@ -203,7 +207,9 @@ class HlsPlaylistView(StreamView): # which seems to take precedence for setting target delay. Yet it also # doesn't seem to hurt, so we can stick with it for now. playlist.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES" + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * track.target_duration:.3f}" + ",PRECISE=YES" ) last_stream_id = first_segment.stream_id diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index e0321e306e3..a2fa18c4d98 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.1"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.2"] } diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 0c1f38938eb..c196e57baa4 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -460,7 +460,7 @@ class TimestampValidator: if packet.dts is None: if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable] raise StreamWorkerError( - f"No dts in {MAX_MISSING_DTS+1} consecutive packets" + f"No dts in {MAX_MISSING_DTS + 1} consecutive packets" ) self._missing_dts += 1 return False diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 5eeb40630f8..1c1357a9b2b 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN from .coordinator import StreamlabsCoordinator @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key = entry.data[CONF_API_KEY] client = StreamlabsClient(api_key) - coordinator = StreamlabsCoordinator(hass, client) + coordinator = StreamlabsCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 5a0073c25d3..e3e966edde0 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import StreamlabsCoordinator from .const import DOMAIN @@ -15,7 +15,7 @@ from .entity import StreamlabsWaterEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water binary sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index 56e67abe222..df4a6056b36 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from streamlabswater.streamlabswater import StreamlabsClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,15 +26,19 @@ class StreamlabsData: class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): """Coordinator for Streamlabs.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: StreamlabsClient, ) -> None: """Coordinator for Streamlabs.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Streamlabs", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 412b2187495..dea3f081326 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import StreamlabsCoordinator @@ -60,7 +60,7 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index d3c85aba1e7..25ed29d3071 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -49,20 +49,20 @@ from .legacy import ( from .models import SpeechMetadata, SpeechResult __all__ = [ - "async_get_provider", - "async_get_speech_to_text_engine", - "async_get_speech_to_text_entity", + "DOMAIN", "AudioBitRates", "AudioChannels", "AudioCodecs", "AudioFormats", "AudioSampleRates", - "DOMAIN", "Provider", - "SpeechToTextEntity", "SpeechMetadata", "SpeechResult", "SpeechResultState", + "SpeechToTextEntity", + "async_get_provider", + "async_get_speech_to_text_engine", + "async_get_speech_to_text_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index d406234c36e..6bcca848ef2 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -9,7 +9,7 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -29,7 +29,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru device tracker by config_entry.""" entry: dict = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index e21102f0b0c..07caa0d6367 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, get_device_info from .const import ( @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru locks by config_entry.""" entry = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index ba9b7d46b06..aa4c4ee16be 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -141,7 +141,7 @@ EV_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru sensors by config_entry.""" entry = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 00da729dccd..7525e73f802 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Subaru Starlink Configuration", + "title": "Subaru Starlink configuration", "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -49,7 +49,7 @@ "options": { "step": { "init": { - "title": "Subaru Starlink Options", + "title": "Subaru Starlink options", "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", "data": { "update_enabled": "Enable vehicle polling" @@ -106,7 +106,7 @@ "fields": { "door": { "name": "Door", - "description": "One of the following: 'all', 'driver', 'tailgate'." + "description": "Which door(s) to open." } } } diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 176b059f3d5..5d317ea5ba3 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.1"] + "requirements": ["pysuezV2==2.0.3"] } diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 1152ebd551b..a162cc6168d 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN @@ -53,7 +53,7 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SuezWaterConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index e7e621d06cd..a042adb9b83 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED @@ -106,7 +106,9 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: SunConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SunConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sun sensor platform.""" diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 7724816d636..71498990b6f 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -11,7 +11,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_sunrise, async_track_sunset from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/sunweg/sensor/__init__.py b/homeassistant/components/sunweg/sensor/__init__.py index e582b5135d3..f71d992bea9 100644 --- a/homeassistant/components/sunweg/sensor/__init__.py +++ b/homeassistant/components/sunweg/sensor/__init__.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import SunWEGData from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType @@ -49,7 +49,7 @@ def get_device_list( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SunWEG sensor.""" name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index 24189fb7de0..c443e1e63df 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 8f04b5b662e..62f9b4b232d 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -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 DataUpdateCoordinator diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index e1f846d63a7..130242b7742 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -38,8 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( - entry, hass, + entry, ) except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index b422e40ef2d..416d56d1bdd 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SurePetcareDataCoordinator @@ -23,7 +23,9 @@ from .entity import SurePetcareEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" @@ -135,8 +137,8 @@ class DeviceConnectivity(SurePetcareBinarySensor): self._attr_is_on = bool(state) if state: self._attr_extra_state_attributes = { - "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', - "hub_rssi": f'{state["signal"]["hub_rssi"]:.2f}', + "device_rssi": f"{state['signal']['device_rssi']:.2f}", + "hub_rssi": f"{state['signal']['hub_rssi']:.2f}", } else: self._attr_extra_state_attributes = {} diff --git a/homeassistant/components/surepetcare/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index a80e96ad185..d8112cebc90 100644 --- a/homeassistant/components/surepetcare/coordinator.py +++ b/homeassistant/components/surepetcare/coordinator.py @@ -33,7 +33,9 @@ SCAN_INTERVAL = timedelta(minutes=3) class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): """Handle Surepetcare data.""" - def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the data handler.""" self.surepy = Surepy( entry.data[CONF_USERNAME], @@ -51,6 +53,7 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]) super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index f960400bcbc..09fadf8be60 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -10,7 +10,7 @@ from surepy.enums import EntityType, LockState as SurepyLockState from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SurePetcareDataCoordinator @@ -18,7 +18,9 @@ from .entity import SurePetcareEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare locks on a config entry.""" diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index b4e7c6203a3..b012878caf7 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW from .coordinator import SurePetcareDataCoordinator @@ -20,7 +20,9 @@ from .entity import SurePetcareEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps sensors.""" diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 3d88182eaa4..897b440a934 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 628f6e95c2a..0d0c4dc6169 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -96,7 +96,9 @@ async def async_setup_entry( }, ) from e - coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset) + coordinator = SwissPublicTransportDataUpdateCoordinator( + hass, entry, opendata, time_offset + ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 58d674f0c26..4dc6efc2e85 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -11,8 +11,8 @@ from opendata_transport.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( DurationSelector, SelectSelector, diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index c4cf2390dd0..32b52122c7d 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -15,7 +15,7 @@ from opendata_transport.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN @@ -61,6 +61,7 @@ class SwissPublicTransportDataUpdateCoordinator( def __init__( self, hass: HomeAssistant, + config_entry: SwissPublicTransportConfigEntry, opendata: OpendataTransport, time_offset: dict[str, int] | None, ) -> None: @@ -68,6 +69,7 @@ class SwissPublicTransportDataUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME), ) diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index 704479b77d6..e41901337f4 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -6,7 +6,7 @@ from typing import Any from opendata_transport import OpendataTransport -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_DESTINATION, diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json index 06a640a06b2..45cf4713705 100644 --- a/homeassistant/components/swiss_public_transport/icons.json +++ b/homeassistant/components/swiss_public_transport/icons.json @@ -10,7 +10,7 @@ "departure2": { "default": "mdi:bus-clock" }, - "duration": { + "trip_duration": { "default": "mdi:timeline-clock" }, "transfers": { diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a0131938a37..6475fe802c2 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -56,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( ], SwissPublicTransportSensorEntityDescription( key="duration", + translation_key="trip_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data_connection: data_connection["duration"], ), SwissPublicTransportSensorEntityDescription( @@ -88,7 +90,7 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SwissPublicTransportConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" unique_id = config_entry.unique_id diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index ef8cc5595e3..1cdbd527467 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid", "too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.", "unknown": "An unknown error was raised by python-opendata-transport" }, @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" }, "time_fixed": { @@ -64,8 +64,8 @@ "departure2": { "name": "Departure +2" }, - "duration": { - "name": "Duration" + "trip_duration": { + "name": "Trip duration" }, "transfers": { "name": "Transfers" diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 66537a4311e..842dc657817 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 61ee2908009..3c173cf5b2a 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import StrEnum import logging -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index 48d555e6616..276496ce614 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 7c6a7ff38ad..8fd9c799bcb 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT from .entity import BaseInvertableEntity @@ -29,7 +29,7 @@ from .entity import BaseInvertableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Cover Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 858379e71df..846e9ae7e80 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BaseToggleEntity @@ -21,7 +21,7 @@ from .entity import BaseToggleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Fan Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index 59b816f7935..c043a354869 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BaseToggleEntity @@ -19,7 +19,7 @@ from .entity import BaseToggleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Light Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index 2095b06bd84..946429e0395 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT from .entity import BaseInvertableEntity @@ -25,7 +25,7 @@ from .entity import BaseInvertableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Lock Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index 7d9a41d9cd9..b96c7c6e0ea 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BaseToggleEntity @@ -19,7 +19,7 @@ from .entity import BaseToggleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Siren Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py index 8626ca3cfb4..2b5f252ac2d 100644 --- a/homeassistant/components/switch_as_x/valve.py +++ b/homeassistant/components/switch_as_x/valve.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT from .entity import BaseInvertableEntity @@ -29,7 +29,7 @@ from .entity import BaseInvertableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Valve Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 758698a7d67..6e4bf004a3d 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -13,9 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -64,10 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass, verify_ssl=False) api = await get_api_object(central_unit, user, password, websession) - coordinator = SwitchBeeCoordinator( - hass, - api, - ) + coordinator = SwitchBeeCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -114,7 +110,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if match := re.match( rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id ): - entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + entity_new_unique_id = f"{new_unique_id}-{match.group('id')}" _LOGGER.debug( "Migrating entity %s from %s to new id %s", entity_entry.entity_id, diff --git a/homeassistant/components/switchbee/button.py b/homeassistant/components/switchbee/button.py index 78b5c0e6888..1ac81ec4e0d 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -15,7 +15,9 @@ from .entity import SwitchBeeEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee button.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index d946ed1761b..7837798b0cb 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -27,7 +27,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -74,7 +74,9 @@ SUPPORTED_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee climate.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index c8d3d58ee09..b2cd53398ab 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index 49400e3c28d..b0ea1707be8 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -10,6 +10,7 @@ from switchbee.api import CentralUnitPolling, CentralUnitWsRPC from switchbee.api.central_unit import SwitchBeeError from switchbee.device import DeviceType, SwitchBeeBaseDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,9 +23,12 @@ _LOGGER = logging.getLogger(__name__) class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevice]]): """Class to manage fetching SwitchBee data API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, swb_api: CentralUnitPolling | CentralUnitWsRPC, ) -> None: """Initialize.""" @@ -39,6 +43,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL_SEC[type(self.api)]), ) diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py index 02f3d7167e3..247063ab18a 100644 --- a/homeassistant/components/switchbee/cover.py +++ b/homeassistant/components/switchbee/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -25,7 +25,9 @@ from .entity import SwitchBeeDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee switch.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 0daa6e204aa..228667540df 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -35,7 +35,9 @@ def _switchbee_brightness_to_hass(value: int) -> int: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee light.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index c502e6f22f5..41538f6fd71 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -25,7 +25,9 @@ from .entity import SwitchBeeDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee switch.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 499a5073872..09bc157d4d2 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -65,6 +65,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], + SupportedModels.REMOTE.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 144872ff315..6d1490c895b 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -75,7 +75,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot curtain based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index fc2d9f491ac..04b4e20b7ce 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -67,7 +67,7 @@ def short_address(address: str) -> str: def name_from_discovery(discovery: SwitchBotAdvertisement) -> str: """Get the name from a discovery.""" - return f'{discovery.data["modelFriendlyName"]} {short_address(discovery.address)}' + return f"{discovery.data['modelFriendlyName']} {short_address(discovery.address)}" class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): @@ -272,7 +272,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _async_discover_devices(self) -> None: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for connectable in (True, False): for discovery_info in async_discovered_service_info(self.hass, connectable): address = discovery_info.address diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 854ab32b657..16b41d75541 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -34,6 +34,7 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1PM = "relay_switch_1pm" RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" + REMOTE = "remote" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -60,6 +61,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, + SwitchbotModel.REMOTE: SupportedModels.REMOTE, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index d2fd073cdcb..3ef0f5625c2 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator @@ -31,7 +31,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot curtain based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index bde69429bc3..282d23bfd1a 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -61,7 +61,7 @@ class SwitchbotEntity( return self.coordinator.device.parsed_data @property - def extra_state_attributes(self) -> Mapping[Any, Any]: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" return {"last_run_success": self._last_run_success} diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 40f96577842..34a24948df1 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -12,7 +12,7 @@ from homeassistant.components.humidifier import ( HumidifierEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry from .entity import SwitchbotSwitchedEntity @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" async_add_entities([SwitchBotHumidifier(entry.runtime_data)]) diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 927ad5120c7..0a2c342ecf0 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switchbot light.""" async_add_entities([SwitchbotLightEntity(entry.runtime_data)]) diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index a3bee5661b2..6bad154813a 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -7,7 +7,7 @@ from switchbot.const import LockStatus from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import SwitchbotEntity async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot lock based on a config entry.""" force_nightlatch = entry.options.get(CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 1b80da43e16..567a33a8f43 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.55.4"] + "requirements": ["PySwitchbot==0.56.1"] } diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml new file mode 100644 index 00000000000..3b8976aeb8e --- /dev/null +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: todo + comment: | + set `PARALLEL_UPDATES` in lock.py + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Consider using snapshots for fixating all the entities a device creates. + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No network discovery. + discovery: + status: done + comment: | + Can be improved: Device type scan filtering is applied to only show devices that are actually supported. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Only one device per config entry. New devices are set up as new entries. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: todo + comment: | + Needs to provide translations for hub2 temperature entity + exception-translations: todo + icon-translations: + status: exempt + comment: | + No custom icons. + reconfiguration-flow: + status: exempt + comment: | + No need for reconfiguration flow. + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 9787521a5e9..025c40bff9e 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -102,7 +102,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 2a5ddaa0cba..c9f93cce604 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -4,7 +4,10 @@ "step": { "user": { "data": { - "address": "Device address" + "address": "MAC address" + }, + "data_description": { + "address": "The Bluetooth MAC address of your SwitchBot device" } }, "confirm": { @@ -14,6 +17,9 @@ "description": "The {name} device requires a password", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password required for the Bot device access" } }, "encrypted_key": { @@ -21,6 +27,10 @@ "data": { "key_id": "Key ID", "encryption_key": "Encryption key" + }, + "data_description": { + "key_id": "The ID of the encryption key", + "encryption_key": "The encryption key for the device" } }, "encrypted_auth": { @@ -28,10 +38,14 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username of your SwitchBot account", + "password": "The password of your SwitchBot account" } }, "encrypted_choose_method": { - "description": "An encrypted SwitchBot device can be set up in Home Assistant in two different ways.\n\nYou can enter the key id and encryption key yourself, or Home Assistant can import them from your SwitchBot account.", + "description": "An encrypted SwitchBot device can be set up in Home Assistant in two different ways.\n\nYou can enter the key ID and encryption key yourself, or Home Assistant can import them from your SwitchBot account.", "menu_options": { "encrypted_auth": "SwitchBot account (recommended)", "encrypted_key": "Enter encryption key manually" @@ -39,7 +53,7 @@ } }, "error": { - "encryption_key_invalid": "Key ID or Encryption key is invalid", + "encryption_key_invalid": "Key ID or encryption key is invalid", "auth_failed": "Authentication failed: {error_detail}" }, "abort": { @@ -47,7 +61,7 @@ "no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.", "unknown": "[%key:common::config_flow::error::unknown%]", "api_error": "Error while communicating with SwitchBot API: {error_detail}", - "switchbot_unsupported_type": "Unsupported Switchbot Type." + "switchbot_unsupported_type": "Unsupported SwitchBot type." } }, "options": { @@ -56,6 +70,10 @@ "data": { "retry_count": "Retry count", "lock_force_nightlatch": "Force Nightlatch operation mode" + }, + "data_description": { + "retry_count": "How many times to retry sending commands to your SwitchBot devices", + "lock_force_nightlatch": "Force Nightlatch operation mode even if Nightlatch is not detected" } } } diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 427496ef20c..fd1e8bb6393 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -9,7 +9,7 @@ import switchbot from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" async_add_entities([SwitchBotSwitch(entry.runtime_data)]) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 827dce550ef..44e130cc7a4 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -8,7 +8,7 @@ from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotA from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN @@ -16,6 +16,7 @@ from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.CLIMATE, Platform.LOCK, Platform.SENSOR, @@ -28,6 +29,7 @@ PLATFORMS: list[Platform] = [ class SwitchbotDevices: """Switchbot devices data.""" + buttons: list[Device] = field(default_factory=list) climates: list[Remote] = field(default_factory=list) switches: list[Device | Remote] = field(default_factory=list) sensors: list[Device] = field(default_factory=list) @@ -43,79 +45,117 @@ class SwitchbotCloudData: devices: SwitchbotDevices -@callback -def prepare_device( +async def coordinator_for_device( hass: HomeAssistant, + entry: ConfigEntry, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], -) -> tuple[Device | Remote, SwitchBotCoordinator]: +) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" coordinator = coordinators_by_id.setdefault( - device.device_id, SwitchBotCoordinator(hass, api, device) + device.device_id, SwitchBotCoordinator(hass, entry, api, device) ) - return (device, coordinator) + + if coordinator.data is None: + await coordinator.async_config_entry_first_refresh() + + return coordinator -@callback -def make_device_data( +async def make_switchbot_devices( hass: HomeAssistant, + entry: ConfigEntry, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> SwitchbotDevices: - """Make device data.""" + """Make SwitchBot devices.""" devices_data = SwitchbotDevices() - for device in devices: - if isinstance(device, Remote) and device.device_type.endswith( - "Air Conditioner" - ): - devices_data.climates.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if ( - isinstance(device, Device) - and ( - device.device_type.startswith("Plug") - or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] - ) - ) or isinstance(device, Remote): - devices_data.switches.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "Meter", - "MeterPlus", - "WoIOSensor", - "Hub 2", - "MeterPro", - "MeterPro(CO2)", - "Relay Switch 1PM", - ]: - devices_data.sensors.append( - prepare_device(hass, api, device, coordinators_by_id) - ) - if isinstance(device, Device) and device.device_type in [ - "K10+", - "K10+ Pro", - "Robot Vacuum Cleaner S1", - "Robot Vacuum Cleaner S1 Plus", - ]: - devices_data.vacuums.append( - prepare_device(hass, api, device, coordinators_by_id) - ) + await gather( + *[ + make_device_data(hass, entry, api, device, devices_data, coordinators_by_id) + for device in devices + ] + ) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): - devices_data.locks.append( - prepare_device(hass, api, device, coordinators_by_id) - ) return devices_data -async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: +async def make_device_data( + hass: HomeAssistant, + entry: ConfigEntry, + api: SwitchBotAPI, + device: Device | Remote, + devices_data: SwitchbotDevices, + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> None: + """Make device data.""" + if isinstance(device, Remote) and device.device_type.endswith("Air Conditioner"): + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.climates.append((device, coordinator)) + if ( + isinstance(device, Device) + and ( + device.device_type.startswith("Plug") + or device.device_type in ["Relay Switch 1PM", "Relay Switch 1"] + ) + ) or isinstance(device, Remote): + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.switches.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "Meter", + "MeterPlus", + "WoIOSensor", + "Hub 2", + "MeterPro", + "MeterPro(CO2)", + "Relay Switch 1PM", + "Plug Mini (US)", + "Plug Mini (JP)", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.sensors.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in [ + "K10+", + "K10+ Pro", + "Robot Vacuum Cleaner S1", + "Robot Vacuum Cleaner S1 Plus", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.vacuums.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.locks.append((device, coordinator)) + + if isinstance(device, Device) and device.device_type in ["Bot"]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + if coordinator.data is not None: + if coordinator.data.get("deviceMode") == "pressMode": + devices_data.buttons.append((device, coordinator)) + else: + devices_data.switches.append((device, coordinator)) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" - token = config.data[CONF_API_TOKEN] - secret = config.data[CONF_API_KEY] + token = entry.data[CONF_API_TOKEN] + secret = entry.data[CONF_API_KEY] api = SwitchBotAPI(token=token, secret=secret) try: @@ -129,14 +169,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) coordinators_by_id: dict[str, SwitchBotCoordinator] = {} + + switchbot_devices = await make_switchbot_devices( + hass, entry, api, devices, coordinators_by_id + ) hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( - api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) - ) - await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather( - *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] + hass.data[DOMAIN][entry.entry_id] = SwitchbotCloudData( + api=api, devices=switchbot_devices ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py new file mode 100644 index 00000000000..aae2758f3ca --- /dev/null +++ b/homeassistant/components/switchbot_cloud/button.py @@ -0,0 +1,41 @@ +"""Support for the Switchbot Bot as a Button.""" + +from typing import Any + +from switchbot_api import BotCommands + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudBot(data.api, device, coordinator) + for device, coordinator in data.devices.buttons + ) + + +class SwitchBotCloudBot(SwitchBotCloudEntity, ButtonEntity): + """Representation of a SwitchBot Bot.""" + + _attr_name = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + async def async_press(self, **kwargs: Any) -> None: + """Bot press command.""" + await self.send_api_command(BotCommands.PRESS) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 4e05e9e9a1e..27698420ae9 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -4,7 +4,7 @@ from typing import Any from switchbot_api import AirConditionerCommands -import homeassistant.components.climate as FanState +from homeassistant.components import climate as FanState from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -42,7 +42,7 @@ _DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py index 0ebd04f7e5a..02ead5940e4 100644 --- a/homeassistant/components/switchbot_cloud/coordinator.py +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -6,6 +6,7 @@ from typing import Any from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,16 +20,22 @@ type Status = dict[str, Any] | None class SwitchBotCoordinator(DataUpdateCoordinator[Status]): """SwitchBot Cloud coordinator.""" + config_entry: ConfigEntry _api: SwitchBotAPI _device_id: str def __init__( - self, hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: SwitchBotAPI, + device: Device | Remote, ) -> None: """Initialize SwitchBot Cloud.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index f77adb7b192..74adcb049c1 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -4,6 +4,7 @@ from typing import Any from switchbot_api import Commands, Device, Remote, SwitchBotAPI +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,3 +49,17 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): command_type, parameters, ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._set_attributes() + super()._handle_coordinator_update() + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await super().async_added_to_hass() + self._set_attributes() diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 2fbd551b919..74a9e9d8b1e 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -6,8 +6,8 @@ from switchbot_api import LockCommands from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -17,7 +17,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -32,12 +32,10 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): _attr_name = None - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: self._attr_is_locked = coord_data["lockState"] == "locked" - self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index eb08d2183b1..99f909e91ab 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.2.1"] + "loggers": ["switchbot_api"], + "requirements": ["switchbot-api==2.3.1"] } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ae912e914ba..28384ffd4d5 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -61,20 +61,27 @@ POWER_DESCRIPTION = SensorEntityDescription( native_unit_of_measurement=UnitOfPower.WATT, ) -VOLATGE_DESCRIPTION = SensorEntityDescription( +VOLTAGE_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ) -CURRENT_DESCRIPTION = SensorEntityDescription( +CURRENT_DESCRIPTION_IN_MA = SensorEntityDescription( key=SENSOR_TYPE_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, ) +CURRENT_DESCRIPTION_IN_A = SensorEntityDescription( + key=SENSOR_TYPE_CURRENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, +) + CO2_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_CO2, device_class=SensorDeviceClass.CO2, @@ -100,8 +107,16 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { ), "Relay Switch 1PM": ( POWER_DESCRIPTION, - VOLATGE_DESCRIPTION, - CURRENT_DESCRIPTION, + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_MA, + ), + "Plug Mini (US)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, + ), + "Plug Mini (JP)": ( + VOLTAGE_DESCRIPTION, + CURRENT_DESCRIPTION_IN_A, ), "Hub 2": ( TEMPERATURE_DESCRIPTION, @@ -124,7 +139,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -151,10 +166,8 @@ class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{device.device_id}_{description.key}" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_native_value = self.coordinator.data.get(self.entity_description.key) - self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 281ebb9322e..ebe20620d3e 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,7 @@ from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotA from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -18,7 +18,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -46,21 +46,18 @@ class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): self._attr_is_on = False self.async_write_ha_state() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value - self.async_write_ha_state() class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot switch provider by a remote.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): @@ -72,13 +69,11 @@ class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): class SwitchBotCloudRelaySwitchSwitch(SwitchBotCloudSwitch): """Representation of a SwitchBot relay switch.""" - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return self._attr_is_on = self.coordinator.data.get("switchStatus") == 1 - self.async_write_ha_state() @callback @@ -95,4 +90,6 @@ def _async_make_entity( "Relay Switch 1", ]: return SwitchBotCloudRelaySwitchSwitch(api, device, coordinator) + if "Bot" in device.device_type: + return SwitchBotCloudSwitch(api, device, coordinator) raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 2d2a1783d73..9a9ad49626f 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -11,7 +11,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import ( @@ -28,7 +28,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -99,9 +99,8 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): """Start or resume the cleaning task.""" await self.send_api_command(VacuumCommands.START) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" if not self.coordinator.data: return @@ -111,8 +110,6 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): switchbot_state = str(self.coordinator.data.get("workingStatus")) self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) - self.async_write_ha_state() - @callback def _async_make_entity( diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index d2686e2e550..30597ed0738 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -20,7 +20,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD @@ -81,7 +81,7 @@ THERMOSTAT_BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: SwitcherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher button from config entry.""" diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 2fc4a331676..c8bf33eca09 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -29,7 +29,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD @@ -62,7 +62,7 @@ HA_TO_DEVICE_FAN = {value: key for key, value in DEVICE_FAN_TO_HA.items()} async def async_setup_entry( hass: HomeAssistant, config_entry: SwitcherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher climate from config entry.""" diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 513b786a033..5d8e4a4b0ac 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator @@ -28,7 +28,7 @@ API_STOP = "stop_shutter" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher cover from config entry.""" diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index 75156044efa..b9dc78f5bdf 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator @@ -22,7 +22,7 @@ API_SET_LIGHT = "set_light" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher light from a config entry.""" diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 0ed60e5a721..029d517bb09 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import SIGNAL_DEVICE_ADD @@ -61,7 +61,7 @@ THERMOSTAT_SENSORS = TEMPERATURE_SENSORS async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher sensor from config entry.""" diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 844cbb4ca98..e380711303d 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -63,6 +63,14 @@ "temperature": { "name": "Current temperature" } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "multi_child_lock": { + "name": "Child lock {cover_id}" + } } }, "services": { diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ba0a99b4089..30b0b4161b1 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -4,18 +4,19 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast -from aioswitcher.api import Command -from aioswitcher.device import DeviceCategory, DeviceState +from aioswitcher.api import Command, ShutterChildLock +from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( @@ -32,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) API_CONTROL_DEVICE = "control_device" API_SET_AUTO_SHUTDOWN = "set_auto_shutdown" +API_SET_CHILD_LOCK = "set_shutter_child_lock" SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, @@ -47,7 +49,7 @@ SERVICE_TURN_ON_WITH_TIMER_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher switch from config entry.""" platform = entity_platform.async_get_current_platform() @@ -67,10 +69,28 @@ async def async_setup_entry( @callback def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" + entities: list[SwitchEntity] = [] + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) + entities.append(SwitcherWaterHeaterSwitchEntity(coordinator)) + elif coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + ): + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append( + SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + ) + else: + entities.extend( + SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + for i in range(number_of_covers) + ) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -154,3 +174,91 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) self.control_result = True self.async_write_ha_state() + + +class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher shutter base switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:lock-open" + _cover_id: int + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.control_result: bool | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._handle_coordinator_update() + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.control_result is not None: + return self.control_result + + data = cast(SwitcherShutter, self.coordinator.data) + return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id + ) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id + ) + self.control_result = False + self.async_write_ha_state() + + +class SwitchereShutterChildLockSingleSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock single switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" + ) + + +class SwitchereShutterChildLockMultiSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock multiple switch entity.""" + + _attr_translation_key = "multi_child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock" + ) diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py index 8484eb5a2d1..0b449c65194 100644 --- a/homeassistant/components/switchmate/switch.py +++ b/homeassistant/components/switchmate/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index fc1f9ae8aea..697ea8aea6e 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -25,7 +25,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Syncthing sensors.""" syncthing = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 2b110c2af1d..e6d26d22433 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,7 +35,7 @@ SYNCTHRU_STATE_PROBLEM = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 1fb155a5648..1407814f838 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -8,10 +8,15 @@ from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN @@ -33,15 +38,15 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_check_and_create("user", user_input) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle SSDP initiated flow.""" - await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN]) self._abort_if_unique_id_configured() self.url = url_normalize( discovery_info.upnp.get( - ssdp.ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info.ssdp_location or '').hostname}/", ) ) @@ -52,11 +57,11 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): # Update unique id of entry with the same URL if not existing_entry.unique_id: self.hass.config_entries.async_update_entry( - existing_entry, unique_id=discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + existing_entry, unique_id=discovery_info.upnp[ATTR_UPNP_UDN] ) return self.async_abort(reason="already_configured") - self.name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "") + self.name = discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, "") if self.name: # Remove trailing " (ip)" if present for consistency with user driven config self.name = re.sub(r"\s+\([\d.]+\)\s*$", "", self.name) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index df2ffd99803..c2063bf6c0a 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -43,7 +43,7 @@ SYNCTHRU_STATE_HUMAN = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py index 38c302b7968..37ea3238a06 100644 --- a/homeassistant/components/synology_chat/notify.py +++ b/homeassistant/components/synology_chat/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_RESOURCE, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_FILE_URL = "file_url" diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 3619619782e..1b26b7df84d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -10,13 +10,16 @@ from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL +from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .common import SynoApi, raise_config_entry_auth_error from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DATA_BACKUP_AGENT_LISTENERS, DEFAULT_VERIFY_SSL, DOMAIN, EXCEPTION_DETAILS, @@ -60,6 +63,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL} ) + if CONF_BACKUP_SHARE not in entry.options: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None}, + ) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) # Continue setup api = SynoApi(hass, entry) @@ -118,6 +130,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + if entry.options[CONF_BACKUP_SHARE]: + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload( + entry.async_on_state_change(async_notify_backup_listeners) + ) + return True diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py new file mode 100644 index 00000000000..670c4c9bef0 --- /dev/null +++ b/homeassistant/components/synology_dsm/backup.py @@ -0,0 +1,275 @@ +"""Support for Synology DSM backup agents.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import TYPE_CHECKING, Any + +from aiohttp import StreamReader +from synology_dsm.api.file_station import SynoFileStation +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import JsonObjectType, json_loads_object + +from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from .models import SynologyDSMData + +LOGGER = logging.getLogger(__name__) + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Suggest filenames for the backup. + + returns a tuple of tar_filename and meta_filename + """ + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return (f"{base_name}.tar", f"{base_name}_meta.json") + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + if not ( + entries := hass.config_entries.async_loaded_entries(DOMAIN) + ) or not hass.data.get(DOMAIN): + LOGGER.debug("No proper config entry found") + return [] + syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] + return [ + SynologyDSMBackupAgent(hass, entry, entry.unique_id) + for entry in entries + if entry.unique_id is not None + and (syno_data := syno_datas.get(entry.unique_id)) + and syno_data.api.file_station + and entry.options.get(CONF_BACKUP_PATH) + ] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class SynologyDSMBackupAgent(BackupAgent): + """Synology DSM backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: + """Initialize the Synology DSM backup agent.""" + super().__init__() + LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) + self.name = entry.title + self.unique_id = unique_id + self.path = ( + f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" + ) + syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + self.api = syno_data.api + self.backup_base_names: dict[str, str] = {} + + @property + def _file_station(self) -> SynoFileStation: + if TYPE_CHECKING: + # we ensure that file_station exist already in async_get_backup_agents + assert self.api.file_station + return self.api.file_station + + async def _async_backup_filenames( + self, + backup_id: str, + ) -> tuple[str, str]: + """Return the actual backup filenames. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: A tuple of tar_filename and meta_filename + """ + if await self.async_get_backup(backup_id) is None: + raise BackupNotFound + base_name = self.backup_base_names[backup_id] + return (f"{base_name}.tar", f"{base_name}_meta.json") + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + (filename_tar, _) = await self._async_backup_filenames(backup_id) + + try: + resp = await self._file_station.download_file( + path=self.path, + filename=filename_tar, + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to download backup") from err + + if TYPE_CHECKING: + assert isinstance(resp, StreamReader) + + return ChunkAsyncStreamIterator(resp) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + + (filename_tar, filename_meta) = suggested_filenames(backup) + + # upload backup.tar file first + try: + await self._file_station.upload_file( + path=self.path, + filename=filename_tar, + source=await open_stream(), + create_parents=True, + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to upload backup") from err + + # upload backup_meta.json file when backup.tar was successful uploaded + try: + await self._file_station.upload_file( + path=self.path, + filename=filename_meta, + source=json_dumps(backup.as_dict()).encode(), + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to upload backup") from err + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + try: + (filename_tar, filename_meta) = await self._async_backup_filenames( + backup_id + ) + except BackupAgentError: + # backup meta data could not be found, so we can't delete the backup + return + + for filename in (filename_tar, filename_meta): + try: + await self._file_station.delete_file(path=self.path, filename=filename) + except SynologyDSMAPIErrorException as err: + err_args: dict = err.args[0] + if int(err_args.get("code", 0)) != 900 or ( + (err_details := err_args.get("details")) is not None + and isinstance(err_details, list) + and isinstance(err_details[0], dict) + and int(err_details[0].get("code", 0)) + != 408 # No such file or directory + ): + LOGGER.error("Failed to delete backup: %s", err) + raise BackupAgentError("Failed to delete backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return list((await self._async_list_backups(**kwargs)).values()) + + async def _async_list_backups(self, **kwargs: Any) -> dict[str, AgentBackup]: + """List backups.""" + + async def _download_meta_data(filename: str) -> JsonObjectType: + try: + resp = await self._file_station.download_file( + path=self.path, filename=filename + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to download meta data") from err + + if TYPE_CHECKING: + assert isinstance(resp, StreamReader) + + try: + return json_loads_object(await resp.read()) + except Exception as err: + raise BackupAgentError("Failed to read meta data") from err + + try: + files = await self._file_station.get_files(path=self.path) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to list backups") from err + + if TYPE_CHECKING: + assert files + + backups: dict[str, AgentBackup] = {} + backup_base_names: dict[str, str] = {} + for file in files: + if file.name.endswith("_meta.json"): + try: + meta_data = await _download_meta_data(file.name) + except BackupAgentError as err: + LOGGER.error("Failed to download meta data: %s", err) + continue + agent_backup = AgentBackup.from_dict(meta_data) + backup_id = agent_backup.backup_id + backups[backup_id] = agent_backup + backup_base_names[backup_id] = file.name.replace("_meta.json", "") + self.backup_base_names = backup_base_names + return backups + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self._async_list_backups() + return backups.get(backup_id) diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index b9c7ff483ea..2f7d041cb10 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN @@ -63,7 +63,9 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS binary sensor.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index fccd0860036..6512c370334 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN @@ -53,7 +53,7 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index cbf17ec05b4..acbcccb8894 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import ( @@ -46,7 +46,9 @@ class SynologyDSMCameraEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS cameras.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 9a6284eff2b..2e80624ca5d 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -7,6 +7,7 @@ from collections.abc import Callable from contextlib import suppress import logging +from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem @@ -14,6 +15,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.file_station import SynoFileStation from synology_dsm.api.photos import SynoPhotos from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -34,13 +36,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( + CONF_BACKUP_PATH, CONF_DEVICE_TOKEN, DEFAULT_TIMEOUT, + DOMAIN, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, + ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) @@ -62,11 +68,12 @@ class SynoApi: self.config_url = f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" # DSM APIs + self.file_station: SynoFileStation | None = None self.information: SynoDSMInformation | None = None self.network: SynoDSMNetwork | None = None + self.photos: SynoPhotos | None = None self.security: SynoCoreSecurity | None = None self.storage: SynoStorage | None = None - self.photos: SynoPhotos | None = None self.surveillance_station: SynoSurveillanceStation | None = None self.system: SynoCoreSystem | None = None self.upgrade: SynoCoreUpgrade | None = None @@ -74,10 +81,11 @@ class SynoApi: # Should we fetch them self._fetching_entities: dict[str, set[str]] = {} + self._with_file_station = True self._with_information = True + self._with_photos = True self._with_security = True self._with_storage = True - self._with_photos = True self._with_surveillance_station = True self._with_system = True self._with_upgrade = True @@ -128,6 +136,9 @@ class SynoApi: ) await self.async_login() + self.information = self.dsm.information + await self.information.update() + # check if surveillance station is used self._with_surveillance_station = bool( self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY) @@ -157,6 +168,42 @@ class SynoApi: self.dsm.reset(SynoCoreUpgrade.API_KEY) LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) + # check if file station is used and permitted + self._with_file_station = bool( + self.information.awesome_version >= AwesomeVersion("6.0") + and self.dsm.apis.get(SynoFileStation.LIST_API_KEY) + ) + if self._with_file_station: + shares: list | None = None + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await self.dsm.file.get_shared_folders(only_writable=True) + if not shares: + self._with_file_station = False + self.dsm.reset(SynoFileStation.API_KEY) + LOGGER.debug( + "File Station found, but disabled due to missing user" + " permissions or no writable shared folders available" + ) + + if shares and not self._entry.options.get(CONF_BACKUP_PATH): + ir.async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_MISSING_BACKUP_SETUP}_{self._entry.unique_id}", + data={"entry_id": self._entry.entry_id}, + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MISSING_BACKUP_SETUP, + translation_placeholders={"title": self._entry.title}, + ) + + LOGGER.debug( + "State of File Station during setup of '%s': %s", + self._entry.unique_id, + self._with_file_station, + ) + await self._fetch_device_configuration() try: @@ -225,6 +272,15 @@ class SynoApi: self.dsm.reset(self.security) self.security = None + if not self._with_file_station: + LOGGER.debug( + "Disable file station api from being updated or '%s'", + self._entry.unique_id, + ) + if self.file_station: + self.dsm.reset(self.file_station) + self.file_station = None + if not self._with_photos: LOGGER.debug( "Disable photos api from being updated or '%s'", self._entry.unique_id @@ -268,10 +324,15 @@ class SynoApi: async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" - self.information = self.dsm.information self.network = self.dsm.network await self.network.update() + if self._with_file_station: + LOGGER.debug( + "Enable file station api updates for '%s'", self._entry.unique_id + ) + self.file_station = self.dsm.file + if self._with_security: LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id) self.security = self.dsm.security diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 918a24035f8..58784862305 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -3,12 +3,14 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from ipaddress import ip_address as ip import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from synology_dsm import SynologyDSM +from synology_dsm.api.file_station.models import SynoFileSharedFolder from synology_dsm.exceptions import ( SynologyDSMException, SynologyDSMLogin2SAFailedException, @@ -18,7 +20,6 @@ from synology_dsm.exceptions import ( ) import voluptuous as vol -from homeassistant.components import ssdp, zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -32,7 +33,6 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -40,23 +40,39 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType +from homeassistant.util import slugify from homeassistant.util.network import is_ip_address as is_ip from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, CONF_DEVICE_TOKEN, CONF_SNAPSHOT_QUALITY, CONF_VOLUMES, + DEFAULT_BACKUP_PATH, DEFAULT_PORT, DEFAULT_PORT_SSL, - DEFAULT_SCAN_INTERVAL, DEFAULT_SNAPSHOT_QUALITY, DEFAULT_TIMEOUT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + SYNOLOGY_CONNECTION_EXCEPTIONS, ) +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -126,6 +142,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.discovered_conf: dict[str, Any] = {} self.reauth_conf: Mapping[str, Any] = {} self.reauth_reason: str | None = None + self.shares: list[SynoFileSharedFolder] | None = None def _show_form( self, @@ -168,6 +185,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) otp_code = user_input.get(CONF_OTP_CODE) friendly_name = user_input.get(CONF_NAME) + backup_path = user_input.get(CONF_BACKUP_PATH) + backup_share = user_input.get(CONF_BACKUP_SHARE) if not port: if use_ssl is True: @@ -204,6 +223,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if errors: return self._show_form(step_id, user_input, errors) + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + self.shares = await api.file.get_shared_folders(only_writable=True) + + if self.shares and not backup_path: + return await self.async_step_backup_share(user_input) + # unique_id should be serial for services purpose existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) @@ -216,6 +241,10 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: password, CONF_MAC: api.network.macs, } + config_options = { + CONF_BACKUP_PATH: backup_path, + CONF_BACKUP_SHARE: backup_share, + } if otp_code: config_data[CONF_DEVICE_TOKEN] = api.device_token if user_input.get(CONF_DISKS): @@ -228,10 +257,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): "reauth_successful" if self.reauth_conf else "reconfigure_successful" ) return self.async_update_reload_and_abort( - existing_entry, data=config_data, reason=reason + existing_entry, data=config_data, options=config_options, reason=reason ) - return self.async_create_entry(title=friendly_name or host, data=config_data) + return self.async_create_entry( + title=friendly_name or host, data=config_data, options=config_options + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -243,7 +274,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_validate_input_create_entry(user_input, step_id=step) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via zeroconf.""" discovered_macs = [ @@ -258,13 +289,13 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_from_discovery(host, friendly_name, discovered_macs) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered synology_dsm via ssdp.""" parsed_url = urlparse(discovery_info.ssdp_location) - upnp_friendly_name: str = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + upnp_friendly_name: str = discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] friendly_name = upnp_friendly_name.split("(", 1)[0].strip() - mac_address = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + mac_address = discovery_info.upnp[ATTR_UPNP_SERIAL] discovered_macs = [format_synology_mac(mac_address)] # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. @@ -363,6 +394,43 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) + async def async_step_backup_share( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Select backup location.""" + if TYPE_CHECKING: + assert self.shares is not None + + if not self.saved_user_input: + self.saved_user_input = user_input + + if CONF_BACKUP_PATH not in user_input and CONF_BACKUP_SHARE not in user_input: + return self.async_show_form( + step_id="backup_share", + data_schema=vol.Schema( + { + vol.Required(CONF_BACKUP_SHARE): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in self.shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=f"{DEFAULT_BACKUP_PATH}_{slugify(self.hass.config.location_name)}", + ): str, + } + ), + ) + + user_input = {**self.saved_user_input, **user_input} + self.saved_user_input = {} + + return await self.async_step_user(user_input) + def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: """See if we already have a configured NAS with this MAC address.""" for entry in self._async_current_entries(): @@ -383,14 +451,10 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) + syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + data_schema = vol.Schema( { - vol.Required( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): cv.positive_int, vol.Required( CONF_SNAPSHOT_QUALITY, default=self.config_entry.options.get( @@ -399,6 +463,36 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): ): vol.All(vol.Coerce(int), vol.Range(min=0, max=2)), } ) + + shares: list[SynoFileSharedFolder] | None = None + if syno_data.api.file_station: + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await syno_data.api.file_station.get_shared_folders( + only_writable=True + ) + + if shares: + data_schema = data_schema.extend( + { + vol.Required( + CONF_BACKUP_SHARE, + default=self.config_entry.options[CONF_BACKUP_SHARE], + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=self.config_entry.options[CONF_BACKUP_PATH], + ): str, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index e6367458578..758fad53970 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable + from aiohttp import ClientTimeout from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED from synology_dsm.exceptions import ( @@ -15,8 +17,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey DOMAIN = "synology_dsm" +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}_backup_agent_listeners" +) ATTRIBUTION = "Data provided by Synology" PLATFORMS = [ Platform.BINARY_SENSOR, @@ -29,20 +35,24 @@ PLATFORMS = [ EXCEPTION_DETAILS = "details" EXCEPTION_UNKNOWN = "unknown" +ISSUE_MISSING_BACKUP_SETUP = "missing_backup_setup" + # Configuration CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" CONF_DEVICE_TOKEN = "device_token" CONF_SNAPSHOT_QUALITY = "snap_profile_type" +CONF_BACKUP_SHARE = "backup_share" +CONF_BACKUP_PATH = "backup_path" DEFAULT_USE_SSL = True DEFAULT_VERIFY_SSL = False DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options -DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED +DEFAULT_BACKUP_PATH = "ha_backup" ENTITY_UNIT_LOAD = "load" diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 357de10b5b8..1b3e21090b8 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -14,14 +14,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import SynoApi, raise_config_entry_auth_error from .const import ( - DEFAULT_SCAN_INTERVAL, SIGNAL_CAMERA_SOURCE_CHANGED, SYNOLOGY_AUTH_FAILED_EXCEPTIONS, SYNOLOGY_CONNECTION_EXCEPTIONS, @@ -59,6 +57,8 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -68,10 +68,10 @@ class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ) -> None: """Initialize synology_dsm DataUpdateCoordinator.""" self.api = api - self.entry = entry super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{entry.title} {self.__class__.__name__}", update_interval=update_interval, ) @@ -120,14 +120,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" - super().__init__( - hass, - entry, - api, - timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - ) + super().__init__(hass, entry, api, timedelta(minutes=15)) @async_re_login_on_expired async def _async_update_data(self) -> None: @@ -174,7 +167,7 @@ class SynologyDSMCameraUpdateCoordinator( ): async_dispatcher_send( self.hass, - f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}", + f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.config_entry.entry_id}_{cam_id}", cam_data_new.live_view.rtsp, ) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index ab6fc20b5cb..dc5634e7a84 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.6.0"], + "requirements": ["py-synologydsm-api==2.7.0"], "ssdp": [ { "manufacturer": "Synology", diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py new file mode 100644 index 00000000000..725e77a2593 --- /dev/null +++ b/homeassistant/components/synology_dsm/repairs.py @@ -0,0 +1,125 @@ +"""Repair flows for the Synology DSM integration.""" + +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import cast + +from synology_dsm.api.file_station.models import SynoFileSharedFolder +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, + ISSUE_MISSING_BACKUP_SETUP, + SYNOLOGY_CONNECTION_EXCEPTIONS, +) +from .models import SynologyDSMData + +LOGGER = logging.getLogger(__name__) + + +class MissingBackupSetupRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + """Create flow.""" + self.entry = entry + self.issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + }, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + + syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + + if user_input is not None: + self.hass.config_entries.async_update_entry( + self.entry, options={**dict(self.entry.options), **user_input} + ) + return self.async_create_entry(data={}) + + shares: list[SynoFileSharedFolder] | None = None + if syno_data.api.file_station: + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await syno_data.api.file_station.get_shared_folders( + only_writable=True + ) + + if not shares: + return self.async_abort(reason="no_shares") + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_BACKUP_SHARE, + default=self.entry.options[CONF_BACKUP_SHARE], + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=self.entry.options[CONF_BACKUP_PATH], + ): str, + } + ), + ) + + async def async_step_ignore( + self, _: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) + return self.async_abort(reason="ignored") + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + entry = hass.config_entries.async_get_entry(entry_id) + + if entry and issue_id.startswith(ISSUE_MISSING_BACKUP_SETUP): + return MissingBackupSetupRepairFlow(entry, issue_id) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index b29a33f7253..2987de7a7c7 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -286,7 +286,9 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS Sensor.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 0f8ea594732..f51184ef1cb 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -21,6 +21,17 @@ "otp_code": "Code" } }, + "backup_share": { + "title": "Synology DSM: Backup location", + "data": { + "backup_share": "Shared folder", + "backup_path": "Path" + }, + "data_description": { + "backup_share": "Select the shared folder, where the automatic Home-Assistant backup should be stored.", + "backup_path": "Define the path on the selected shared folder (will automatically be created, if not exist)." + } + }, "link": { "description": "Do you want to set up {name} ({host})?", "data": { @@ -57,9 +68,13 @@ "step": { "init": { "data": { - "scan_interval": "Minutes between scans", - "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" } } } @@ -170,6 +185,37 @@ } } }, + "issues": { + "missing_backup_setup": { + "title": "Backup location not configured for {title}", + "fix_flow": { + "step": { + "init": { + "description": "The backup location for {title} is not configured. Do you want to set it up now? Details can be found in the integration documentation under [Backup Location]({docs_url})", + "menu_options": { + "confirm": "Set up the backup location now", + "ignore": "Don't set it up now" + } + }, + "confirm": { + "title": "[%key:component::synology_dsm::config::step::backup_share::title%]", + "data": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" + } + } + }, + "abort": { + "no_shares": "There are no shared folders available for the user.\nPlease check the documentation.", + "ignored": "The backup location has not been configured.\nYou can still set it up later via the integration options." + } + } + } + }, "services": { "reboot": { "name": "Reboot", diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index facce824bda..c4f1572ceea 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN @@ -40,7 +40,9 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS switch.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index ed60191f296..71eed2d7f1f 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -12,7 +12,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SynologyDSMCentralUpdateCoordinator @@ -38,7 +38,9 @@ UPDATE_ENTITIES: Final = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Synology DSM update entities.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 3e0e7add185..b916be84acf 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 019b1df4639..0140499a75a 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator @@ -65,7 +65,9 @@ BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, .. async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge binary sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 98396e52545..60b57b1e87f 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -16,13 +16,13 @@ from systembridgeconnector.websocket_client import WebSocketClient from systembridgemodels.modules import GetData, Module import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DATA_WAIT_TIMEOUT, DOMAIN @@ -179,7 +179,7 @@ class SystemBridgeConfigFlow( ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" properties = discovery_info.properties diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 7151805f154..1690bad4a4d 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -40,6 +40,8 @@ from .data import SystemBridgeData class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]): """Class to manage fetching System Bridge data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -65,6 +67,7 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index aeff3b22fb2..6d3bbd21a05 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator @@ -66,7 +66,7 @@ MEDIA_PLAYER_DESCRIPTION: Final[MediaPlayerEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge media players based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 94c73a2ac05..c7cae2f347b 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util @@ -359,7 +359,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py index b0d341cee3b..12060c28669 100644 --- a/homeassistant/components/system_bridge/update.py +++ b/homeassistant/components/system_bridge/update.py @@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator @@ -16,7 +16,7 @@ from .entity import SystemBridgeEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge update based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index ce80f6303d9..37e9ee3d929 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable import dataclasses from datetime import datetime import logging @@ -101,6 +101,57 @@ async def get_integration_info( return result +async def _registered_domain_data( + hass: HomeAssistant, +) -> AsyncGenerator[tuple[str, dict[str, Any]]]: + registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] + for domain, domain_data in zip( + registrations, + await asyncio.gather( + *( + get_integration_info(hass, registration) + for registration in registrations.values() + ) + ), + strict=False, + ): + yield domain, domain_data + + +async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]: + """Get the full set of system health information.""" + domains: dict[str, dict[str, Any]] = {} + + async def _get_info_value(value: Any) -> Any: + if not asyncio.iscoroutine(value): + return value + try: + return await value + except Exception as exception: + _LOGGER.exception("Error fetching system info for %s - %s", domain, key) + return f"Exception: {exception}" + + async for domain, domain_data in _registered_domain_data(hass): + domain_info: dict[str, Any] = {} + for key, value in domain_data["info"].items(): + info_value = await _get_info_value(value) + + if isinstance(info_value, datetime): + domain_info[key] = info_value.isoformat() + elif ( + isinstance(info_value, dict) + and "type" in info_value + and info_value["type"] == "failed" + ): + domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}" + else: + domain_info[key] = info_value + + domains[domain] = domain_info + + return domains + + @callback def _format_value(val: Any) -> Any: """Format a system health value.""" @@ -115,20 +166,10 @@ async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle an info request via a subscription.""" - registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] data = {} pending_info: dict[tuple[str, str], asyncio.Task] = {} - for domain, domain_data in zip( - registrations, - await asyncio.gather( - *( - get_integration_info(hass, registration) - for registration in registrations.values() - ) - ), - strict=False, - ): + async for domain, domain_data in _registered_domain_data(hass): for key, value in domain_data["info"].items(): if asyncio.iscoroutine(value): value = asyncio.create_task(value) @@ -179,7 +220,7 @@ async def handle_info( # Update subscription of all finished tasks for result in done: domain, key = pending_lookup[result] - event_msg = { + event_msg: dict[str, Any] = { "type": "update", "domain": domain, "key": key, diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 22950aa9f1e..facfb270627 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -16,7 +16,7 @@ from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType type KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None] @@ -163,16 +163,16 @@ class LogEntry: """Store HA log entries.""" __slots__ = ( + "count", + "exception", "first_occurred", - "timestamp", - "name", + "key", "level", "message", - "exception", + "name", "root_cause", "source", - "count", - "key", + "timestamp", ) def __init__( diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py index aecd30765ff..3968e94ec03 100644 --- a/homeassistant/components/systemmonitor/binary_sensor.py +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -91,7 +91,7 @@ SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SystemMonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Monitor binary sensors based on a config entry.""" coordinator = entry.runtime_data.coordinator diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 048d7cefd6c..e70bccf0833 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -397,7 +397,7 @@ IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INE async def async_setup_entry( hass: HomeAssistant, entry: SystemMonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Monitor sensors based on a config entry.""" entities: list[SystemMonitorSensor] = [] diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index cc5dee77617..4b0203acda3 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -3,14 +3,15 @@ from datetime import timedelta import logging -import requests.exceptions +import PyTado +import PyTado.exceptions +from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -21,17 +22,16 @@ from .const import ( CONST_OVERLAY_TADO_OPTIONS, DOMAIN, ) +from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator +from .models import TadoData from .services import setup_services -from .tado_connector import TadoConnector - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.SWITCH, Platform.WATER_HEATER, ] @@ -41,16 +41,17 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Tado.""" setup_services(hass) - return True -type TadoConfigEntry = ConfigEntry[TadoConnector] +type TadoConfigEntry = ConfigEntry[TadoData] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: @@ -58,59 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _async_import_options_from_data_if_missing(hass, entry) - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) - - tadoconnector = TadoConnector(hass, username, password, fallback) - + _LOGGER.debug("Setting up Tado connection") try: - await hass.async_add_executor_job(tadoconnector.setup) - except KeyError: - _LOGGER.error("Failed to login to tado") - return False - except RuntimeError as exc: - _LOGGER.error("Failed to setup tado: %s", exc) - return False - except requests.exceptions.Timeout as ex: - raise ConfigEntryNotReady from ex - except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: - _LOGGER.error("Failed to login to tado: %s", ex) - return False - raise ConfigEntryNotReady from ex - - # Do first update - await hass.async_add_executor_job(tadoconnector.update) - - # Poll for updates in the background - entry.async_on_unload( - async_track_time_interval( - hass, - lambda now: tadoconnector.update(), - SCAN_INTERVAL, + tado = await hass.async_add_executor_job( + Tado, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) + except PyTado.exceptions.TadoWrongCredentialsException as err: + raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err + except PyTado.exceptions.TadoException as err: + raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err + _LOGGER.debug( + "Tado connection established for username: %s", entry.data[CONF_USERNAME] ) - entry.async_on_unload( - async_track_time_interval( - hass, - lambda now: tadoconnector.update_mobile_devices(), - SCAN_MOBILE_DEVICE_INTERVAL, - ) - ) + coordinator = TadoDataUpdateCoordinator(hass, entry, tado) + await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - - entry.runtime_data = tadoconnector + mobile_coordinator = TadoMobileDeviceUpdateCoordinator(hass, entry, tado) + await mobile_coordinator.async_config_entry_first_refresh() + entry.runtime_data = TadoData(coordinator, mobile_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @callback -def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): +def _async_import_options_from_data_if_missing( + hass: HomeAssistant, entry: TadoConfigEntry +): options = dict(entry.options) if CONF_FALLBACK not in options: options[CONF_FALLBACK] = entry.data.get( @@ -126,7 +106,7 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi hass.config_entries.async_update_entry(entry, options=options) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 25c1c801155..8cec32e20f0 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -13,21 +13,19 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TadoConfigEntry from .const import ( - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_BATTERY, TYPE_HEATING, TYPE_HOT_WATER, TYPE_POWER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoDeviceEntity, TadoZoneEntity -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -117,11 +115,13 @@ ZONE_SENSORS = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado sensor platform.""" - tado = entry.runtime_data + tado = entry.runtime_data.coordinator devices = tado.devices zones = tado.zones entities: list[BinarySensorEntity] = [] @@ -164,43 +164,23 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, device_info: dict[str, Any], entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(device_info) + super().__init__(device_info, coordinator) self._attr_unique_id = ( - f"{entity_description.key} {self.device_id} {tado.home_id}" + f"{entity_description.key} {self.device_id} {coordinator.home_id}" ) - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "device", self.device_id - ), - self._async_update_callback, - ) - ) - self._async_update_device_data() - @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_device_data() - self.async_write_ha_state() - - @callback - def _async_update_device_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - self._device_info = self._tado.data["device"][self.device_id] + self._device_info = self.coordinator.data["device"][self.device_id] except KeyError: return @@ -209,6 +189,7 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._device_info ) + super()._handle_coordinator_update() class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): @@ -218,42 +199,24 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) + self._attr_unique_id = ( + f"{entity_description.key} {zone_id} {coordinator.home_id}" ) - self._async_update_zone_data() @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self.coordinator.data["zone"][self.zone_id] except KeyError: return @@ -262,3 +225,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_zone_data ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 5a81e951293..e6aa921d428 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -26,11 +26,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import TadoConfigEntry, TadoConnector +from . import TadoConfigEntry from .const import ( CONST_EXCLUSIVE_OVERLAY_GROUP, CONST_FAN_AUTO, @@ -50,7 +49,6 @@ from .const import ( HA_TO_TADO_HVAC_MODE_MAP, ORDERED_KNOWN_TADO_MODES, PRESET_AUTO, - SIGNAL_TADO_UPDATE_RECEIVED, SUPPORT_PRESET_AUTO, SUPPORT_PRESET_MANUAL, TADO_DEFAULT_MAX_TEMP, @@ -73,6 +71,7 @@ from .const import ( TYPE_AIR_CONDITIONING, TYPE_HEATING, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoZoneEntity from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes @@ -101,12 +100,14 @@ CLIMATE_TEMP_OFFSET_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado climate platform.""" - tado = entry.runtime_data - entities = await hass.async_add_executor_job(_generate_entities, tado) + tado = entry.runtime_data.coordinator + entities = await _generate_entities(tado) platform = entity_platform.async_get_current_platform() @@ -125,12 +126,12 @@ async def async_setup_entry( async_add_entities(entities, True) -def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: +async def _generate_entities(tado: TadoDataUpdateCoordinator) -> list[TadoClimate]: """Create all climate entities.""" entities = [] for zone in tado.zones: if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: - entity = create_climate_entity( + entity = await create_climate_entity( tado, zone["name"], zone["id"], zone["devices"][0] ) if entity: @@ -138,11 +139,11 @@ def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: return entities -def create_climate_entity( - tado: TadoConnector, name: str, zone_id: int, device_info: dict +async def create_climate_entity( + tado: TadoDataUpdateCoordinator, name: str, zone_id: int, device_info: dict ) -> TadoClimate | None: """Create a Tado climate entity.""" - capabilities = tado.get_capabilities(zone_id) + capabilities = await tado.get_capabilities(zone_id) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) zone_type = capabilities["type"] @@ -243,6 +244,8 @@ def create_climate_entity( cool_max_temp = float(cool_temperatures["celsius"]["max"]) cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) + auto_geofencing_supported = await tado.get_auto_geofencing_supported() + return TadoClimate( tado, name, @@ -251,6 +254,8 @@ def create_climate_entity( supported_hvac_modes, support_flags, device_info, + capabilities, + auto_geofencing_supported, heat_min_temp, heat_max_temp, heat_step, @@ -272,13 +277,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, zone_type: str, supported_hvac_modes: list[HVACMode], support_flags: ClimateEntityFeature, device_info: dict[str, str], + capabilities: dict[str, str], + auto_geofencing_supported: bool, heat_min_temp: float | None = None, heat_max_temp: float | None = None, heat_step: float | None = None, @@ -289,13 +296,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): supported_swing_modes: list[str] | None = None, ) -> None: """Initialize of Tado climate entity.""" - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + self._tado = coordinator + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) self.zone_id = zone_id self.zone_type = zone_type - self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_type} {zone_id} {coordinator.home_id}" self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -327,36 +334,61 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_vertical_swing = TADO_SWING_OFF self._current_tado_horizontal_swing = TADO_SWING_OFF - capabilities = tado.get_capabilities(zone_id) self._current_tado_capabilities = capabilities + self._auto_geofencing_supported = auto_geofencing_supported self._tado_zone_data: PyTado.TadoZone = {} self._tado_geofence_data: dict[str, str] | None = None self._tado_zone_temp_offset: dict[str, Any] = {} - self._async_update_home_data() self._async_update_zone_data() - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), - self._async_update_home_callback, - ) - ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_zone_data() + super()._handle_coordinator_update() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_zone_callback, + @callback + def _async_update_zone_data(self) -> None: + """Load tado data into zone.""" + self._tado_geofence_data = self._tado.data["geofence"] + self._tado_zone_data = self._tado.data["zone"][self.zone_id] + + # Assign offset values to mapped attributes + for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items(): + if ( + self._device_id in self._tado.data["device"] + and offset_key + in self._tado.data["device"][self._device_id][TEMP_OFFSET] + ): + self._tado_zone_temp_offset[attr] = self._tado.data["device"][ + self._device_id + ][TEMP_OFFSET][offset_key] + + self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode + self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action + + if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + self._current_tado_fan_level = self._tado_zone_data.current_fan_level + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): + self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed + if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): + self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): + self._current_tado_vertical_swing = ( + self._tado_zone_data.current_vertical_swing_mode ) - ) + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): + self._current_tado_horizontal_swing = ( + self._tado_zone_data.current_horizontal_swing_mode + ) + + @callback + def _async_update_zone_callback(self) -> None: + """Load tado data and update state.""" + self._async_update_zone_data() @property def current_humidity(self) -> int | None: @@ -401,12 +433,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): return FAN_AUTO return None - def set_fan_mode(self, fan_mode: str) -> None: + async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) + await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) elif self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + await self.coordinator.async_request_refresh() @property def preset_mode(self) -> str: @@ -425,13 +458,14 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" - if self._tado.get_auto_geofencing_supported(): + if self._auto_geofencing_supported: return SUPPORT_PRESET_AUTO return SUPPORT_PRESET_MANUAL - def set_preset_mode(self, preset_mode: str) -> None: + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - self._tado.set_presence(preset_mode) + await self._tado.set_presence(preset_mode) + await self.coordinator.async_request_refresh() @property def target_temperature_step(self) -> float | None: @@ -449,7 +483,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): # the device is switching states return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp - def set_timer( + async def set_timer( self, temperature: float, time_period: int | None = None, @@ -457,14 +491,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ): """Set the timer on the entity, and temperature if supported.""" - self._control_hvac( + await self._control_hvac( hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period, overlay_mode=requested_overlay, ) + await self.coordinator.async_request_refresh() - def set_temp_offset(self, offset: float) -> None: + async def set_temp_offset(self, offset: float) -> None: """Set offset on the entity.""" _LOGGER.debug( @@ -473,9 +508,10 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): offset, ) - self._tado.set_temperature_offset(self._device_id, offset) + await self._tado.set_temperature_offset(self._device_id, offset) + await self.coordinator.async_request_refresh() - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return @@ -485,15 +521,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): CONST_MODE_AUTO, CONST_MODE_SMART_SCHEDULE, ): - self._control_hvac(target_temp=temperature) + await self._control_hvac(target_temp=temperature) + await self.coordinator.async_request_refresh() return new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT - self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) + await self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) + await self.coordinator.async_request_refresh() - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + _LOGGER.debug( + "Setting new hvac mode for device %s to %s", self._device_id, hvac_mode + ) + await self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + await self.coordinator.async_request_refresh() @property def available(self) -> bool: @@ -559,7 +601,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) return state_attr - def set_swing_mode(self, swing_mode: str) -> None: + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing modes for the device.""" vertical_swing = None horizontal_swing = None @@ -591,62 +633,12 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): horizontal_swing = TADO_SWING_ON - self._control_hvac( + await self._control_hvac( swing_mode=swing, vertical_swing=vertical_swing, horizontal_swing=horizontal_swing, ) - - @callback - def _async_update_zone_data(self) -> None: - """Load tado data into zone.""" - self._tado_zone_data = self._tado.data["zone"][self.zone_id] - - # Assign offset values to mapped attributes - for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items(): - if ( - self._device_id in self._tado.data["device"] - and offset_key - in self._tado.data["device"][self._device_id][TEMP_OFFSET] - ): - self._tado_zone_temp_offset[attr] = self._tado.data["device"][ - self._device_id - ][TEMP_OFFSET][offset_key] - - self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action - - if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): - self._current_tado_fan_level = self._tado_zone_data.current_fan_level - if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): - self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed - if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): - self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode - if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): - self._current_tado_vertical_swing = ( - self._tado_zone_data.current_vertical_swing_mode - ) - if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): - self._current_tado_horizontal_swing = ( - self._tado_zone_data.current_horizontal_swing_mode - ) - - @callback - def _async_update_zone_callback(self) -> None: - """Load tado data and update state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_home_data(self) -> None: - """Load tado geofencing data into zone.""" - self._tado_geofence_data = self._tado.data["geofence"] - - @callback - def _async_update_home_callback(self) -> None: - """Load tado data and update state.""" - self._async_update_home_data() - self.async_write_ha_state() + await self.coordinator.async_request_refresh() def _normalize_target_temp_for_hvac_mode(self) -> None: def adjust_temp(min_temp, max_temp) -> float | None: @@ -665,7 +657,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): elif self._current_tado_hvac_mode == CONST_MODE_HEAT: self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp) - def _control_hvac( + async def _control_hvac( self, hvac_mode: str | None = None, target_temp: float | None = None, @@ -712,7 +704,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) + await self._tado.set_zone_off( + self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type + ) return if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: @@ -721,17 +715,17 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_name, self.zone_id, ) - self._tado.reset_zone_overlay(self.zone_id) + await self._tado.reset_zone_overlay(self.zone_id) return overlay_mode = decide_overlay_mode( - tado=self._tado, + coordinator=self._tado, duration=duration, overlay_mode=overlay_mode, zone_id=self.zone_id, ) duration = decide_duration( - tado=self._tado, + coordinator=self._tado, duration=duration, zone_id=self.zone_id, overlay_mode=overlay_mode, @@ -785,7 +779,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ): swing = self._current_tado_swing_mode - self._tado.set_zone_overlay( + await self._tado.set_zone_overlay( zone_id=self.zone_id, overlay_mode=overlay_mode, # What to do when the period ends temperature=temperature_to_send, @@ -800,18 +794,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool: - return ( - self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get( - setting - ) - is not None + """Determine if a setting is valid for the current HVAC mode.""" + capabilities: str | dict[str, str] = self._current_tado_capabilities.get( + self._current_tado_hvac_mode, {} ) + if isinstance(capabilities, dict): + return capabilities.get(setting) is not None + return False def _is_current_setting_supported_by_current_hvac_mode( self, setting: str, current_state: str | None ) -> bool: - if self._is_valid_setting_for_hvac_mode(setting): - return current_state in self._current_tado_capabilities[ - self._current_tado_hvac_mode - ].get(setting, []) + """Determine if the current setting is supported by the current HVAC mode.""" + capabilities: str | dict[str, str] = self._current_tado_capabilities.get( + self._current_tado_hvac_mode, {} + ) + if isinstance(capabilities, dict) and self._is_valid_setting_for_hvac_mode( + setting + ): + return current_state in capabilities.get(setting, []) return False diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index c7bb7684901..f251a292800 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -10,7 +10,6 @@ from PyTado.interface import Tado import requests.exceptions import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import ( CONF_FALLBACK, @@ -49,7 +52,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, tado = await hass.async_add_executor_job( Tado, data[CONF_USERNAME], data[CONF_PASSWORD] ) - tado_me = await hass.async_add_executor_job(tado.getMe) + tado_me = await hass.async_add_executor_job(tado.get_me) except KeyError as ex: raise InvalidAuth from ex except RuntimeError as ex: @@ -104,14 +107,14 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() properties = { key.lower(): value for (key, value) in discovery_info.properties.items() } - await self.async_set_unique_id(properties[zeroconf.ATTR_PROPERTIES_ID]) + await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py new file mode 100644 index 00000000000..559bc4a16fb --- /dev/null +++ b/homeassistant/components/tado/coordinator.py @@ -0,0 +1,408 @@ +"""Coordinator for the Tado integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import TYPE_CHECKING, Any + +from PyTado.interface import Tado +from requests import RequestException + +from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +if TYPE_CHECKING: + from . import TadoConfigEntry + +from .const import ( + CONF_FALLBACK, + CONST_OVERLAY_TADO_DEFAULT, + DOMAIN, + INSIDE_TEMPERATURE_MEASUREMENT, + PRESET_AUTO, + TEMP_OFFSET, +) + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) +SCAN_INTERVAL = timedelta(minutes=5) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) + + +class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage API calls from and to Tado via PyTado.""" + + tado: Tado + home_id: int + home_name: str + config_entry: TadoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TadoConfigEntry, + tado: Tado, + debug: bool = False, + ) -> None: + """Initialize the Tado data update coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._tado = tado + self._username = config_entry.data[CONF_USERNAME] + self._password = config_entry.data[CONF_PASSWORD] + self._fallback = config_entry.options.get( + CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT + ) + self._debug = debug + + self.home_id: int + self.home_name: str + self.zones: list[dict[Any, Any]] = [] + self.devices: list[dict[Any, Any]] = [] + self.data: dict[str, dict] = { + "device": {}, + "weather": {}, + "geofence": {}, + "zone": {}, + } + + @property + def fallback(self) -> str: + """Return fallback flag to Smart Schedule.""" + return self._fallback + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the (initial) latest data from Tado.""" + + try: + _LOGGER.debug("Preloading home data") + tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me) + _LOGGER.debug("Preloading zones and devices") + self.zones = await self.hass.async_add_executor_job(self._tado.get_zones) + self.devices = await self.hass.async_add_executor_job( + self._tado.get_devices + ) + except RequestException as err: + raise UpdateFailed(f"Error during Tado setup: {err}") from err + + tado_home = tado_home_call["homes"][0] + self.home_id = tado_home["id"] + self.home_name = tado_home["name"] + + devices = await self._async_update_devices() + zones = await self._async_update_zones() + home = await self._async_update_home() + + self.data["device"] = devices + self.data["zone"] = zones + self.data["weather"] = home["weather"] + self.data["geofence"] = home["geofence"] + + return self.data + + async def _async_update_devices(self) -> dict[str, dict]: + """Update the device data from Tado.""" + + try: + devices = await self.hass.async_add_executor_job(self._tado.get_devices) + except RequestException as err: + _LOGGER.error("Error updating Tado devices: %s", err) + raise UpdateFailed(f"Error updating Tado devices: {err}") from err + + if not devices: + _LOGGER.error("No linked devices found for home ID %s", self.home_id) + raise UpdateFailed(f"No linked devices found for home ID {self.home_id}") + + return await self.hass.async_add_executor_job(self._update_device_info, devices) + + def _update_device_info(self, devices: list[dict[str, Any]]) -> dict[str, dict]: + """Update the device data from Tado.""" + mapped_devices: dict[str, dict] = {} + for device in devices: + device_short_serial_no = device["shortSerialNo"] + _LOGGER.debug("Updating device %s", device_short_serial_no) + try: + if ( + INSIDE_TEMPERATURE_MEASUREMENT + in device["characteristics"]["capabilities"] + ): + _LOGGER.debug( + "Updating temperature offset for device %s", + device_short_serial_no, + ) + device[TEMP_OFFSET] = self._tado.get_device_info( + device_short_serial_no, TEMP_OFFSET + ) + except RequestException as err: + _LOGGER.error( + "Error updating device %s: %s", device_short_serial_no, err + ) + + _LOGGER.debug( + "Device %s updated, with data: %s", device_short_serial_no, device + ) + mapped_devices[device_short_serial_no] = device + + return mapped_devices + + async def _async_update_zones(self) -> dict[int, dict]: + """Update the zone data from Tado.""" + + try: + zone_states_call = await self.hass.async_add_executor_job( + self._tado.get_zone_states + ) + zone_states = zone_states_call["zoneStates"] + except RequestException as err: + _LOGGER.error("Error updating Tado zones: %s", err) + raise UpdateFailed(f"Error updating Tado zones: {err}") from err + + mapped_zones: dict[int, dict] = {} + for zone in zone_states: + mapped_zones[int(zone)] = await self._update_zone(int(zone)) + + return mapped_zones + + async def _update_zone(self, zone_id: int) -> dict[str, str]: + """Update the internal data of a zone.""" + + _LOGGER.debug("Updating zone %s", zone_id) + try: + data = await self.hass.async_add_executor_job( + self._tado.get_zone_state, zone_id + ) + except RequestException as err: + _LOGGER.error("Error updating Tado zone %s: %s", zone_id, err) + raise UpdateFailed(f"Error updating Tado zone {zone_id}: {err}") from err + + _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) + return data + + async def _async_update_home(self) -> dict[str, dict]: + """Update the home data from Tado.""" + + try: + weather = await self.hass.async_add_executor_job(self._tado.get_weather) + geofence = await self.hass.async_add_executor_job(self._tado.get_home_state) + except RequestException as err: + _LOGGER.error("Error updating Tado home: %s", err) + raise UpdateFailed(f"Error updating Tado home: {err}") from err + + _LOGGER.debug( + "Home data updated, with weather and geofence data: %s, %s", + weather, + geofence, + ) + + return {"weather": weather, "geofence": geofence} + + async def get_capabilities(self, zone_id: int | str) -> dict: + """Fetch the capabilities from Tado.""" + + try: + return await self.hass.async_add_executor_job( + self._tado.get_capabilities, zone_id + ) + except RequestException as err: + raise UpdateFailed(f"Error updating Tado data: {err}") from err + + async def get_auto_geofencing_supported(self) -> bool: + """Fetch the auto geofencing supported from Tado.""" + + try: + return await self.hass.async_add_executor_job( + self._tado.get_auto_geofencing_supported + ) + except RequestException as err: + raise UpdateFailed(f"Error updating Tado data: {err}") from err + + async def reset_zone_overlay(self, zone_id): + """Reset the zone back to the default operation.""" + + try: + await self.hass.async_add_executor_job( + self._tado.reset_zone_overlay, zone_id + ) + await self._update_zone(zone_id) + except RequestException as err: + raise UpdateFailed(f"Error resetting Tado data: {err}") from err + + async def set_presence( + self, + presence=PRESET_HOME, + ): + """Set the presence to home, away or auto.""" + + if presence == PRESET_AWAY: + await self.hass.async_add_executor_job(self._tado.set_away) + elif presence == PRESET_HOME: + await self.hass.async_add_executor_job(self._tado.set_home) + elif presence == PRESET_AUTO: + await self.hass.async_add_executor_job(self._tado.set_auto) + + async def set_zone_overlay( + self, + zone_id=None, + overlay_mode=None, + temperature=None, + duration=None, + device_type="HEATING", + mode=None, + fan_speed=None, + swing=None, + fan_level=None, + vertical_swing=None, + horizontal_swing=None, + ) -> None: + """Set a zone overlay.""" + + _LOGGER.debug( + "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s, fan_speed=%s, swing=%s, fan_level=%s, vertical_swing=%s, horizontal_swing=%s", + zone_id, + overlay_mode, + temperature, + duration, + device_type, + mode, + fan_speed, + swing, + fan_level, + vertical_swing, + horizontal_swing, + ) + + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_overlay, + zone_id, + overlay_mode, + temperature, + duration, + device_type, + "ON", + mode, + fan_speed, + swing, + fan_level, + vertical_swing, + horizontal_swing, + ) + + except RequestException as err: + raise UpdateFailed(f"Error setting Tado overlay: {err}") from err + + await self._update_zone(zone_id) + + async def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): + """Set a zone to off.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_overlay, + zone_id, + overlay_mode, + None, + None, + device_type, + "OFF", + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado overlay: {err}") from err + + await self._update_zone(zone_id) + + async def set_temperature_offset(self, device_id, offset): + """Set temperature offset of device.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_temp_offset, device_id, offset + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado temperature offset: {err}") from err + + async def set_meter_reading(self, reading: int) -> dict[str, Any]: + """Send meter reading to Tado.""" + dt: str = datetime.now().strftime("%Y-%m-%d") + if self._tado is None: + raise HomeAssistantError("Tado client is not initialized") + + try: + return await self.hass.async_add_executor_job( + self._tado.set_eiq_meter_readings, dt, reading + ) + except RequestException as err: + raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err + + async def set_child_lock(self, device_id: str, enabled: bool) -> None: + """Set child lock of device.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_child_lock, + device_id, + enabled, + ) + except RequestException as exc: + raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + + +class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage the mobile devices from Tado via PyTado.""" + + config_entry: TadoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TadoConfigEntry, + tado: Tado, + ) -> None: + """Initialize the Tado data update coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_MOBILE_DEVICE_INTERVAL, + ) + self._tado = tado + self.data: dict[str, dict] = {} + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the latest data from Tado.""" + + try: + mobile_devices = await self.hass.async_add_executor_job( + self._tado.get_mobile_devices + ) + except RequestException as err: + _LOGGER.error("Error updating Tado mobile devices: %s", err) + raise UpdateFailed(f"Error updating Tado mobile devices: {err}") from err + + mapped_mobile_devices: dict[str, dict] = {} + for mobile_device in mobile_devices: + mobile_device_id = mobile_device["id"] + _LOGGER.debug("Updating mobile device %s", mobile_device_id) + try: + mapped_mobile_devices[mobile_device_id] = mobile_device + _LOGGER.debug( + "Mobile device %s updated, with data: %s", + mobile_device_id, + mobile_device, + ) + except RequestException: + _LOGGER.error( + "Unable to connect to Tado while updating mobile device %s", + mobile_device_id, + ) + + self.data["mobile_device"] = mapped_mobile_devices + return self.data diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 95e031329c3..34aca2dd833 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -11,12 +11,15 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import TadoConfigEntry -from .const import DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED -from .tado_connector import TadoConnector +from .const import DOMAIN +from .coordinator import TadoMobileDeviceUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -24,11 +27,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: TadoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") - tado = entry.runtime_data + tado = entry.runtime_data.mobile_coordinator tracked: set = set() # Fix non-string unique_id for device trackers @@ -49,58 +52,56 @@ async def async_setup_entry( update_devices() - entry.async_on_unload( - async_dispatcher_connect( - hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id), - update_devices, - ) - ) - @callback def add_tracked_entities( hass: HomeAssistant, - tado: TadoConnector, - async_add_entities: AddEntitiesCallback, + coordinator: TadoMobileDeviceUpdateCoordinator, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from Tado.""" _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities") new_tracked = [] - for device_key, device in tado.data["mobile_device"].items(): + for device_key, device in coordinator.data["mobile_device"].items(): if device_key in tracked: continue _LOGGER.debug( "Adding Tado device %s with deviceID %s", device["name"], device_key ) - new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado)) + new_tracked.append( + TadoDeviceTrackerEntity(device_key, device["name"], coordinator) + ) tracked.add(device_key) async_add_entities(new_tracked) -class TadoDeviceTrackerEntity(TrackerEntity): +class TadoDeviceTrackerEntity(CoordinatorEntity[DataUpdateCoordinator], TrackerEntity): """A Tado Device Tracker entity.""" - _attr_should_poll = False _attr_available = False def __init__( self, device_id: str, device_name: str, - tado: TadoConnector, + coordinator: TadoMobileDeviceUpdateCoordinator, ) -> None: """Initialize a Tado Device Tracker entity.""" - super().__init__() + super().__init__(coordinator) self._attr_unique_id = str(device_id) self._device_id = device_id self._device_name = device_name - self._tado = tado self._active = False + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_state() + super()._handle_coordinator_update() + @callback def update_state(self) -> None: """Update the Tado device.""" @@ -109,7 +110,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self._device_name, self._device_id, ) - device = self._tado.data["mobile_device"][self._device_id] + device = self.coordinator.data["mobile_device"][self._device_id] self._attr_available = False _LOGGER.debug( @@ -129,25 +130,6 @@ class TadoDeviceTrackerEntity(TrackerEntity): else: _LOGGER.debug("Tado device %s is not at home", device["name"]) - @callback - def on_demand_update(self) -> None: - """Update state on demand.""" - self.update_state() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register state update callback.""" - _LOGGER.debug("Registering Tado device tracker entity") - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id), - self.on_demand_update, - ) - ) - - self.update_state() - @property def name(self) -> str: """Return the name of the device.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 6bb90ab849a..971b2863aba 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -1,21 +1,30 @@ """Base class for Tado entity.""" +import logging + from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TadoConnector from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE +from .coordinator import TadoDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) -class TadoDeviceEntity(Entity): - """Base implementation for Tado device.""" +class TadoCoordinatorEntity(CoordinatorEntity[TadoDataUpdateCoordinator]): + """Base class for Tado entity.""" - _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device_info: dict[str, str]) -> None: + +class TadoDeviceEntity(TadoCoordinatorEntity): + """Base implementation for Tado device.""" + + def __init__( + self, device_info: dict[str, str], coordinator: TadoDataUpdateCoordinator + ) -> None: """Initialize a Tado device.""" - super().__init__() + super().__init__(coordinator) self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] @@ -30,35 +39,35 @@ class TadoDeviceEntity(Entity): ) -class TadoHomeEntity(Entity): +class TadoHomeEntity(TadoCoordinatorEntity): """Base implementation for Tado home.""" - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__(self, tado: TadoConnector) -> None: + def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None: """Initialize a Tado home.""" - super().__init__() - self.home_name = tado.home_name - self.home_id = tado.home_id + super().__init__(coordinator) + self.home_name = coordinator.home_name + self.home_id = coordinator.home_id self._attr_device_info = DeviceInfo( configuration_url="https://app.tado.com", - identifiers={(DOMAIN, str(tado.home_id))}, + identifiers={(DOMAIN, str(coordinator.home_id))}, manufacturer=DEFAULT_NAME, model=TADO_HOME, - name=tado.home_name, + name=coordinator.home_name, ) -class TadoZoneEntity(Entity): +class TadoZoneEntity(TadoCoordinatorEntity): """Base implementation for Tado zone.""" - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None: + def __init__( + self, + zone_name: str, + home_id: int, + zone_id: int, + coordinator: TadoDataUpdateCoordinator, + ) -> None: """Initialize a Tado zone.""" - super().__init__() + super().__init__(coordinator) self.zone_name = zone_name self.zone_id = zone_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 558aee164d0..571a757a3e8 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -5,26 +5,27 @@ from .const import ( CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, ) -from .tado_connector import TadoConnector +from .coordinator import TadoDataUpdateCoordinator def decide_overlay_mode( - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, duration: int | None, zone_id: int, overlay_mode: str | None = None, ) -> str: """Return correct overlay mode based on the action and defaults.""" + # If user gave duration then overlay mode needs to be timer if duration: return CONST_OVERLAY_TIMER # If no duration or timer set to fallback setting if overlay_mode is None: - overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE + overlay_mode = coordinator.fallback or CONST_OVERLAY_TADO_MODE # If default is Tado default then look it up if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: overlay_mode = ( - tado.data["zone"][zone_id].default_overlay_termination_type + coordinator.data["zone"][zone_id].default_overlay_termination_type or CONST_OVERLAY_TADO_MODE ) @@ -32,18 +33,19 @@ def decide_overlay_mode( def decide_duration( - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, duration: int | None, zone_id: int, overlay_mode: str | None = None, ) -> None | int: """Return correct duration based on the selected overlay mode/duration and tado config.""" + # If we ended up with a timer but no duration, set a default duration # If we ended up with a timer but no duration, set a default duration if overlay_mode == CONST_OVERLAY_TIMER and duration is None: duration = ( - int(tado.data["zone"][zone_id].default_overlay_termination_duration) - if tado.data["zone"][zone_id].default_overlay_termination_duration + int(coordinator.data["zone"][zone_id].default_overlay_termination_duration) + if coordinator.data["zone"][zone_id].default_overlay_termination_duration is not None else 3600 ) @@ -53,6 +55,7 @@ def decide_duration( def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): """Return correct list of fan modes or None.""" + supported_fanmodes = [ tado_to_ha_mapping.get(option) for option in options diff --git a/homeassistant/components/tado/icons.json b/homeassistant/components/tado/icons.json index c799bef0260..65b86359950 100644 --- a/homeassistant/components/tado/icons.json +++ b/homeassistant/components/tado/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "switch": { + "child_lock": { + "default": "mdi:lock-open-variant", + "state": { + "on": "mdi:lock" + } + } + } + }, "services": { "set_climate_timer": { "service": "mdi:timer" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 856a0c5402b..b83e2695137 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.5"] + "requirements": ["python-tado==0.18.6"] } diff --git a/homeassistant/components/tado/models.py b/homeassistant/components/tado/models.py new file mode 100644 index 00000000000..08bdaceaf03 --- /dev/null +++ b/homeassistant/components/tado/models.py @@ -0,0 +1,13 @@ +"""Models for use in Tado integration.""" + +from dataclasses import dataclass + +from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator + + +@dataclass +class TadoData: + """Class to hold Tado data.""" + + coordinator: TadoDataUpdateCoordinator + mobile_coordinator: TadoMobileDeviceUpdateCoordinator diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 8bb13a02cd1..d0d54e79670 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TadoConfigEntry @@ -24,13 +23,12 @@ from .const import ( CONDITIONS_MAP, SENSOR_DATA_CATEGORY_GEOFENCE, SENSOR_DATA_CATEGORY_WEATHER, - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoHomeEntity, TadoZoneEntity -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -193,11 +191,13 @@ ZONE_SENSORS = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado sensor platform.""" - tado = entry.runtime_data + tado = entry.runtime_data.coordinator zones = tado.zones entities: list[SensorEntity] = [] @@ -232,39 +232,22 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): entity_description: TadoSensorEntityDescription def __init__( - self, tado: TadoConnector, entity_description: TadoSensorEntityDescription + self, + coordinator: TadoDataUpdateCoordinator, + entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - super().__init__(tado) - self._tado = tado + super().__init__(coordinator) - self._attr_unique_id = f"{entity_description.key} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), - self._async_update_callback, - ) - ) - self._async_update_home_data() + self._attr_unique_id = f"{entity_description.key} {coordinator.home_id}" @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_home_data() - self.async_write_ha_state() - - @callback - def _async_update_home_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_weather_data = self._tado.data["weather"] - tado_geofence_data = self._tado.data["geofence"] + tado_weather_data = self.coordinator.data["weather"] + tado_geofence_data = self.coordinator.data["geofence"] except KeyError: return @@ -278,6 +261,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_sensor_data ) + super()._handle_coordinator_update() class TadoZoneSensor(TadoZoneEntity, SensorEntity): @@ -287,43 +271,24 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" - - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) + self._attr_unique_id = ( + f"{entity_description.key} {zone_id} {coordinator.home_id}" ) - self._async_update_zone_data() @callback - def _async_update_callback(self) -> None: - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self) -> None: - """Handle update callbacks.""" + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" try: - tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self.coordinator.data["zone"][self.zone_id] except KeyError: return @@ -332,3 +297,4 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): self._attr_extra_state_attributes = self.entity_description.attributes_fn( tado_zone_data ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py index 89711808066..d931ea303e9 100644 --- a/homeassistant/components/tado/services.py +++ b/homeassistant/components/tado/services.py @@ -43,11 +43,8 @@ def setup_services(hass: HomeAssistant) -> None: if entry is None: raise ServiceValidationError("Config entry not found") - tadoconnector = entry.runtime_data - - response: dict = await hass.async_add_executor_job( - tadoconnector.set_meter_reading, call.data[CONF_READING] - ) + coordinator = entry.runtime_data.coordinator + response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) if ATTR_MESSAGE in response: raise HomeAssistantError(response[ATTR_MESSAGE]) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 8124570f9c9..ff1afc3c03d 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -14,7 +14,7 @@ }, "reconfigure": { "title": "Reconfigure your Tado", - "description": "Reconfigure the entry, for your account: `{username}`.", + "description": "Reconfigure the entry for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, @@ -25,7 +25,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this tado account.", + "no_homes": "There are no homes linked to this Tado account.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } @@ -33,7 +33,7 @@ "options": { "step": { "init": { - "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Dont change until you cancel; TADO_DEFAULT:= Change based on your setting in Tado App).", + "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Don't change until you cancel; TADO_DEFAULT:= Change based on your setting in the Tado app).", "data": { "fallback": "Choose fallback mode." }, @@ -64,6 +64,11 @@ } } }, + "switch": { + "child_lock": { + "name": "Child lock" + } + }, "sensor": { "outdoor_temperature": { "name": "Outdoor temperature" @@ -102,11 +107,11 @@ }, "time_period": { "name": "Time period", - "description": "Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay." + "description": "Choose this or 'Overlay'. Set the time period for the change if you want to be specific." }, "requested_overlay": { "name": "Overlay", - "description": "Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting." + "description": "Choose this or 'Time period'. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on Tado app setting." } } }, @@ -135,12 +140,12 @@ } }, "add_meter_reading": { - "name": "Add meter readings", - "description": "Add meter readings to Tado Energy IQ.", + "name": "Add meter reading", + "description": "Adds a meter reading to Tado Energy IQ.", "fields": { "config_entry": { "name": "Config Entry", - "description": "Config entry to add meter readings to." + "description": "Config entry to add meter reading to." }, "reading": { "name": "Reading", @@ -151,8 +156,8 @@ }, "issues": { "water_heater_fallback": { - "title": "Tado Water Heater entities now support fallback options", - "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." + "title": "Tado water heater entities now support fallback options", + "description": "Due to added support for water heaters entities, these entities may use a different overlay. Please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." } } } diff --git a/homeassistant/components/tado/switch.py b/homeassistant/components/tado/switch.py new file mode 100644 index 00000000000..b3f355462b8 --- /dev/null +++ b/homeassistant/components/tado/switch.py @@ -0,0 +1,88 @@ +"""Module for Tado child lock switch entity.""" + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TadoConfigEntry +from .entity import TadoDataUpdateCoordinator, TadoZoneEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tado switch platform.""" + + tado = entry.runtime_data.coordinator + entities: list[TadoChildLockSwitchEntity] = [] + for zone in tado.zones: + zoneChildLockSupported = ( + len(zone["devices"]) > 0 and "childLockEnabled" in zone["devices"][0] + ) + + if not zoneChildLockSupported: + continue + + entities.append( + TadoChildLockSwitchEntity( + tado, zone["name"], zone["id"], zone["devices"][0] + ) + ) + async_add_entities(entities, True) + + +class TadoChildLockSwitchEntity(TadoZoneEntity, SwitchEntity): + """Representation of a Tado child lock switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: TadoDataUpdateCoordinator, + zone_name: str, + zone_id: int, + device_info: dict[str, Any], + ) -> None: + """Initialize the Tado child lock switch entity.""" + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) + + self._device_info = device_info + self._device_id = self._device_info["shortSerialNo"] + self._attr_unique_id = f"{zone_id} {coordinator.home_id} child-lock" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.coordinator.set_child_lock(self._device_id, True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.coordinator.set_child_lock(self._device_id, False) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_callback() + super()._handle_coordinator_update() + + @callback + def _async_update_callback(self) -> None: + """Handle update callbacks.""" + try: + self._device_info = self.coordinator.data["device"][self._device_id] + except KeyError: + _LOGGER.error( + "Could not update child lock info for device %s in zone %s", + self._device_id, + self.zone_name, + ) + else: + self._attr_is_on = self._device_info.get("childLockEnabled", False) is True diff --git a/homeassistant/components/tado/tado_connector.py b/homeassistant/components/tado/tado_connector.py deleted file mode 100644 index 5ed53675153..00000000000 --- a/homeassistant/components/tado/tado_connector.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Tado Connector a class to store the data as an object.""" - -from datetime import datetime, timedelta -import logging -from typing import Any - -from PyTado.interface import Tado -from requests import RequestException - -from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.util import Throttle - -from .const import ( - INSIDE_TEMPERATURE_MEASUREMENT, - PRESET_AUTO, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, - SIGNAL_TADO_UPDATE_RECEIVED, - TEMP_OFFSET, -) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) -SCAN_INTERVAL = timedelta(minutes=5) -SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) - - -_LOGGER = logging.getLogger(__name__) - - -class TadoConnector: - """An object to store the Tado data.""" - - def __init__( - self, hass: HomeAssistant, username: str, password: str, fallback: str - ) -> None: - """Initialize Tado Connector.""" - self.hass = hass - self._username = username - self._password = password - self._fallback = fallback - - self.home_id: int = 0 - self.home_name = None - self.tado = None - self.zones: list[dict[Any, Any]] = [] - self.devices: list[dict[Any, Any]] = [] - self.data: dict[str, dict] = { - "device": {}, - "mobile_device": {}, - "weather": {}, - "geofence": {}, - "zone": {}, - } - - @property - def fallback(self): - """Return fallback flag to Smart Schedule.""" - return self._fallback - - def setup(self): - """Connect to Tado and fetch the zones.""" - self.tado = Tado(self._username, self._password) - # Load zones and devices - self.zones = self.tado.get_zones() - self.devices = self.tado.get_devices() - tado_home = self.tado.get_me()["homes"][0] - self.home_id = tado_home["id"] - self.home_name = tado_home["name"] - - def get_mobile_devices(self): - """Return the Tado mobile devices.""" - return self.tado.get_mobile_devices() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update the registered zones.""" - self.update_devices() - self.update_mobile_devices() - self.update_zones() - self.update_home() - - def update_mobile_devices(self) -> None: - """Update the mobile devices.""" - try: - mobile_devices = self.get_mobile_devices() - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating mobile devices") - return - - if not mobile_devices: - _LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id) - return - - # Errors are planned to be converted to exceptions - # in PyTado library, so this can be removed - if isinstance(mobile_devices, dict) and mobile_devices.get("errors"): - _LOGGER.error( - "Error for home ID %s while updating mobile devices: %s", - self.home_id, - mobile_devices["errors"], - ) - return - - for mobile_device in mobile_devices: - self.data["mobile_device"][mobile_device["id"]] = mobile_device - _LOGGER.debug( - "Dispatching update to %s mobile device: %s", - self.home_id, - mobile_device, - ) - - dispatcher_send( - self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id), - ) - - def update_devices(self): - """Update the device data from Tado.""" - try: - devices = self.tado.get_devices() - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating devices") - return - - if not devices: - _LOGGER.debug("No linked devices found for home ID %s", self.home_id) - return - - # Errors are planned to be converted to exceptions - # in PyTado library, so this can be removed - if isinstance(devices, dict) and devices.get("errors"): - _LOGGER.error( - "Error for home ID %s while updating devices: %s", - self.home_id, - devices["errors"], - ) - return - - for device in devices: - device_short_serial_no = device["shortSerialNo"] - _LOGGER.debug("Updating device %s", device_short_serial_no) - try: - if ( - INSIDE_TEMPERATURE_MEASUREMENT - in device["characteristics"]["capabilities"] - ): - device[TEMP_OFFSET] = self.tado.get_device_info( - device_short_serial_no, TEMP_OFFSET - ) - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating device %s", - device_short_serial_no, - ) - return - - self.data["device"][device_short_serial_no] = device - - _LOGGER.debug( - "Dispatching update to %s device %s: %s", - self.home_id, - device_short_serial_no, - device, - ) - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self.home_id, "device", device_short_serial_no - ), - ) - - def update_zones(self): - """Update the zone data from Tado.""" - try: - zone_states = self.tado.get_zone_states()["zoneStates"] - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating zones") - return - - for zone in zone_states: - self.update_zone(int(zone)) - - def update_zone(self, zone_id): - """Update the internal data from Tado.""" - _LOGGER.debug("Updating zone %s", zone_id) - try: - data = self.tado.get_zone_state(zone_id) - except RuntimeError: - _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) - return - - self.data["zone"][zone_id] = data - - _LOGGER.debug( - "Dispatching update to %s zone %s: %s", - self.home_id, - zone_id, - data, - ) - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), - ) - - def update_home(self): - """Update the home data from Tado.""" - try: - self.data["weather"] = self.tado.get_weather() - self.data["geofence"] = self.tado.get_home_state() - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), - ) - except RuntimeError: - _LOGGER.error( - "Unable to connect to Tado while updating weather and geofence data" - ) - return - - def get_capabilities(self, zone_id): - """Return the capabilities of the devices.""" - return self.tado.get_capabilities(zone_id) - - def get_auto_geofencing_supported(self): - """Return whether the Tado Home supports auto geofencing.""" - return self.tado.get_auto_geofencing_supported() - - def reset_zone_overlay(self, zone_id): - """Reset the zone back to the default operation.""" - self.tado.reset_zone_overlay(zone_id) - self.update_zone(zone_id) - - def set_presence( - self, - presence=PRESET_HOME, - ): - """Set the presence to home, away or auto.""" - if presence == PRESET_AWAY: - self.tado.set_away() - elif presence == PRESET_HOME: - self.tado.set_home() - elif presence == PRESET_AUTO: - self.tado.set_auto() - - # Update everything when changing modes - self.update_zones() - self.update_home() - - def set_zone_overlay( - self, - zone_id=None, - overlay_mode=None, - temperature=None, - duration=None, - device_type="HEATING", - mode=None, - fan_speed=None, - swing=None, - fan_level=None, - vertical_swing=None, - horizontal_swing=None, - ): - """Set a zone overlay.""" - _LOGGER.debug( - ( - "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s," - " type=%s, mode=%s fan_speed=%s swing=%s fan_level=%s vertical_swing=%s horizontal_swing=%s" - ), - zone_id, - overlay_mode, - temperature, - duration, - device_type, - mode, - fan_speed, - swing, - fan_level, - vertical_swing, - horizontal_swing, - ) - - try: - self.tado.set_zone_overlay( - zone_id, - overlay_mode, - temperature, - duration, - device_type, - "ON", - mode, - fan_speed=fan_speed, - swing=swing, - fan_level=fan_level, - vertical_swing=vertical_swing, - horizontal_swing=horizontal_swing, - ) - - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) - - self.update_zone(zone_id) - - def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): - """Set a zone to off.""" - try: - self.tado.set_zone_overlay( - zone_id, overlay_mode, None, None, device_type, "OFF" - ) - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) - - self.update_zone(zone_id) - - def set_temperature_offset(self, device_id, offset): - """Set temperature offset of device.""" - try: - self.tado.set_temp_offset(device_id, offset) - except RequestException as exc: - _LOGGER.error("Could not set temperature offset: %s", exc) - - def set_meter_reading(self, reading: int) -> dict[str, Any]: - """Send meter reading to Tado.""" - dt: str = datetime.now().strftime("%Y-%m-%d") - if self.tado is None: - raise HomeAssistantError("Tado client is not initialized") - - try: - return self.tado.set_eiq_meter_readings(date=dt, reading=reading) - except RequestException as exc: - raise HomeAssistantError("Could not set meter reading") from exc diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 6c964cfaddd..3d8825b264f 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,8 +12,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TadoConfigEntry @@ -26,13 +25,12 @@ from .const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, - SIGNAL_TADO_UPDATE_RECEIVED, TYPE_HOT_WATER, ) +from .coordinator import TadoDataUpdateCoordinator from .entity import TadoZoneEntity from .helper import decide_duration, decide_overlay_mode from .repairs import manage_water_heater_fallback_issue -from .tado_connector import TadoConnector _LOGGER = logging.getLogger(__name__) @@ -63,12 +61,15 @@ WATER_HEATER_TIMER_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado water heater platform.""" - tado = entry.runtime_data - entities = await hass.async_add_executor_job(_generate_entities, tado) + data = entry.runtime_data + coordinator = data.coordinator + entities = await _generate_entities(coordinator) platform = entity_platform.async_get_current_platform() @@ -83,27 +84,29 @@ async def async_setup_entry( manage_water_heater_fallback_issue( hass=hass, water_heater_names=[e.zone_name for e in entities], - integration_overlay_fallback=tado.fallback, + integration_overlay_fallback=coordinator.fallback, ) -def _generate_entities(tado: TadoConnector) -> list: +async def _generate_entities(coordinator: TadoDataUpdateCoordinator) -> list: """Create all water heater entities.""" entities = [] - for zone in tado.zones: + for zone in coordinator.zones: if zone["type"] == TYPE_HOT_WATER: - entity = create_water_heater_entity( - tado, zone["name"], zone["id"], str(zone["name"]) + entity = await create_water_heater_entity( + coordinator, zone["name"], zone["id"], str(zone["name"]) ) entities.append(entity) return entities -def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zone: str): +async def create_water_heater_entity( + coordinator: TadoDataUpdateCoordinator, name: str, zone_id: int, zone: str +): """Create a Tado water heater device.""" - capabilities = tado.get_capabilities(zone_id) + capabilities = await coordinator.get_capabilities(zone_id) supports_temperature_control = capabilities["canSetTemperature"] @@ -116,7 +119,7 @@ def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zon max_temp = None return TadoWaterHeater( - tado, + coordinator, name, zone_id, supports_temperature_control, @@ -134,7 +137,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def __init__( self, - tado: TadoConnector, + coordinator: TadoDataUpdateCoordinator, zone_name: str, zone_id: int, supports_temperature_control: bool, @@ -142,11 +145,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): max_temp, ) -> None: """Initialize of Tado water heater entity.""" - self._tado = tado - super().__init__(zone_name, tado.home_id, zone_id) + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) self.zone_id = zone_id - self._attr_unique_id = f"{zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_id} {coordinator.home_id}" self._device_is_active = False @@ -164,19 +166,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self._overlay_mode = CONST_MODE_SMART_SCHEDULE self._tado_zone_data: Any = None - async def async_added_to_hass(self) -> None: - """Register for sensor updates.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "zone", self.zone_id - ), - self._async_update_callback, - ) - ) self._async_update_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_data() + super()._handle_coordinator_update() + @property def current_operation(self) -> str | None: """Return current readable operation mode.""" @@ -202,7 +199,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Return the maximum temperature.""" return self._max_temperature - def set_operation_mode(self, operation_mode: str) -> None: + async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new operation mode.""" mode = None @@ -213,18 +210,20 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): elif operation_mode == MODE_HEAT: mode = CONST_MODE_HEAT - self._control_heater(hvac_mode=mode) + await self._control_heater(hvac_mode=mode) + await self.coordinator.async_request_refresh() - def set_timer(self, time_period: int, temperature: float | None = None): + async def set_timer(self, time_period: int, temperature: float | None = None): """Set the timer on the entity, and temperature if supported.""" if not self._supports_temperature_control and temperature is not None: temperature = None - self._control_heater( + await self._control_heater( hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period ) + await self.coordinator.async_request_refresh() - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if not self._supports_temperature_control or temperature is None: @@ -235,10 +234,11 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): CONST_MODE_AUTO, CONST_MODE_SMART_SCHEDULE, ): - self._control_heater(target_temp=temperature) + await self._control_heater(target_temp=temperature) return - self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) + await self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) + await self.coordinator.async_request_refresh() @callback def _async_update_callback(self) -> None: @@ -250,10 +250,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def _async_update_data(self) -> None: """Load tado data.""" _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + self._tado_zone_data = self.coordinator.data["zone"][self.zone_id] self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - def _control_heater( + async def _control_heater( self, hvac_mode: str | None = None, target_temp: float | None = None, @@ -276,23 +276,26 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self.zone_name, self.zone_id, ) - self._tado.reset_zone_overlay(self.zone_id) + await self.coordinator.reset_zone_overlay(self.zone_id) + await self.coordinator.async_request_refresh() return if self._current_tado_hvac_mode == CONST_MODE_OFF: _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) + await self.coordinator.set_zone_off( + self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER + ) return overlay_mode = decide_overlay_mode( - tado=self._tado, + coordinator=self.coordinator, duration=duration, zone_id=self.zone_id, ) duration = decide_duration( - tado=self._tado, + coordinator=self.coordinator, duration=duration, zone_id=self.zone_id, overlay_mode=overlay_mode, @@ -304,7 +307,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self.zone_id, self._target_temp, ) - self._tado.set_zone_overlay( + await self.coordinator.set_zone_overlay( zone_id=self.zone_id, overlay_mode=overlay_mode, temperature=self._target_temp, diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 47c1d14ce60..8d42596d3db 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -13,14 +13,16 @@ from homeassistant.components import websocket_api from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.hass_dict import HassKey from .const import DEFAULT_NAME, DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, LOGGER, TAG_ID diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 981f871de09..6569b40ada2 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import TailscaleEntity @@ -84,7 +84,7 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tailscale binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tailscale/coordinator.py b/homeassistant/components/tailscale/coordinator.py index 64ce0147664..1b29cfbf4be 100644 --- a/homeassistant/components/tailscale/coordinator.py +++ b/homeassistant/components/tailscale/coordinator.py @@ -19,18 +19,22 @@ class TailscaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the Tailscale coordinator.""" - self.config_entry = entry - session = async_get_clientsession(hass) self.tailscale = Tailscale( session=session, - api_key=entry.data[CONF_API_KEY], - tailnet=entry.data[CONF_TAILNET], + api_key=config_entry.data[CONF_API_KEY], + tailnet=config_entry.data[CONF_TAILNET], ) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) async def _async_update_data(self) -> dict[str, Device]: """Fetch devices from Tailscale.""" diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index fa4c966a7d7..cf944aa73ef 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import TailscaleEntity @@ -55,7 +55,7 @@ SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tailscale sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index d2f8e1e2ced..4d927b0769e 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity @@ -41,7 +41,7 @@ DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind binary sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index edff3434866..380eb7ccd7e 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TailwindConfigEntry @@ -43,7 +43,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind button based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 48fe2d23727..daf0fbd32b7 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,8 +15,6 @@ from gotailwind import ( ) import voluptuous as vol -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow @@ -27,6 +25,8 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER @@ -83,7 +83,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery of a Tailwind device.""" if not (device_id := discovery_info.properties.get("device_id")): diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 8ea1c7d4f6d..84f38c7d579 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -20,7 +20,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER from .coordinator import TailwindConfigEntry @@ -30,7 +30,7 @@ from .entity import TailwindDoorEntity async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind cover based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index ec13dc7bd1f..dafb46e6f63 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -58,7 +58,7 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, via_device=(DOMAIN, coordinator.data.device_id), - name=f"Door {coordinator.data.doors[door_id].index+1}", + name=f"Door {coordinator.data.doors[door_id].index + 1}", manufacturer="Tailwind", model=coordinator.data.product, sw_version=coordinator.data.firmware_version, diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index b67df9a6a25..ca6b610c351 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TailwindConfigEntry @@ -47,7 +47,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind number based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 8c597409c77..8b9a5e1a90f 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except exceptions.TokenRefreshFailedException as ex: raise ConfigEntryNotReady("Error connecting to API") from ex - coordinator = Tami4EdgeCoordinator(hass, api) + coordinator = Tami4EdgeCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py index 11377a2dcfb..a1b8db79674 100644 --- a/homeassistant/components/tami4/button.py +++ b/homeassistant/components/tami4/button.py @@ -11,7 +11,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API, DOMAIN from .entity import Tami4EdgeBaseEntity @@ -41,7 +41,9 @@ BOIL_WATER_BUTTON = Tami4EdgeButtonEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Tami4Edge.""" diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py index 72b19470f45..a58c801c403 100644 --- a/homeassistant/components/tami4/config_flow.py +++ b/homeassistant/components/tami4/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN diff --git a/homeassistant/components/tami4/coordinator.py b/homeassistant/components/tami4/coordinator.py index 4764562bc34..f65c819b3d8 100644 --- a/homeassistant/components/tami4/coordinator.py +++ b/homeassistant/components/tami4/coordinator.py @@ -7,6 +7,7 @@ import logging from Tami4EdgeAPI import Tami4EdgeAPI, exceptions from Tami4EdgeAPI.water_quality import WaterQuality +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,11 +37,16 @@ class FlattenedWaterQuality: class Tami4EdgeCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): """Tami4Edge water quality coordinator.""" - def __init__(self, hass: HomeAssistant, api: Tami4EdgeAPI) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: Tami4EdgeAPI + ) -> None: """Initialize the water quality coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tami4Edge water quality coordinator", update_interval=timedelta(minutes=60), ) diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py index 888acda9372..2bfd3079c19 100644 --- a/homeassistant/components/tami4/sensor.py +++ b/homeassistant/components/tami4/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import API, COORDINATOR, DOMAIN @@ -52,7 +52,9 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Tami4Edge.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 6d4327a1d06..e9377e346d4 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index a500549a648..b2b60db9675 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -17,11 +17,7 @@ async def async_setup_entry( """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator( - hass, - name=entry.unique_id or DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, - ) + coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 774262a8854..a38266e57e8 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the tankerkoenig binary sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 509f293665d..8796ae46ab7 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -31,8 +31,8 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( LocationSelector, NumberSelector, diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 17e94f62fe9..1f73d0577b3 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS +from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf def __init__( self, hass: HomeAssistant, - name: str, + config_entry: TankerkoenigConfigEntry, update_interval: int, ) -> None: """Initialize the data object.""" @@ -47,7 +47,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf super().__init__( hass=hass, logger=_LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.unique_id or DOMAIN, update_interval=timedelta(minutes=update_interval), ) diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 5970f3d3b24..b1646489d96 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -9,7 +9,7 @@ from aiotankerkoenig import GasType, Station from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_BRAND, @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the tankerkoenig sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py index 0eb612bdc8e..beba9c91538 100644 --- a/homeassistant/components/tapsaff/binary_sensor.py +++ b/homeassistant/components/tapsaff/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_LOCATION, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 8a4b501af05..3b2e640b807 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.components import binary_sensor from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import event as evt from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -26,7 +26,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota binary sensor dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 9deb846f8e2..5b1adc839ac 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -66,8 +66,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: bad_prefix = False prefix = user_input[CONF_DISCOVERY_PREFIX] - if prefix.endswith("/#"): - prefix = prefix[:-2] + prefix = prefix.removesuffix("/#") try: valid_subscribe_topic(f"{prefix}/#") except vol.Invalid: diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 2cb3cfeea25..1d7aa8316b6 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -28,7 +28,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota cover dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index e927bd6ad72..c89b36577be 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -16,7 +16,7 @@ from homeassistant.components.fan import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -36,7 +36,7 @@ ORDERED_NAMED_FAN_SPEEDS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota fan dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index a06e77eceb1..ed66fa128dc 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -31,7 +31,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import DATA_REMOVE_DISCOVER_COMPONENT @@ -45,7 +45,7 @@ TASMOTA_BRIGHTNESS_MAX = 100 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota light dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 783483c6ffd..2e0d8af2338 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.9.2"] + "requirements": ["HATasmota==0.10.0"] } diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 8cc538e706a..ec20e1c0348 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -40,7 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -243,7 +243,7 @@ SENSOR_UNIT_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota sensor dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py index b5c19fc2431..03e594b125c 100644 --- a/homeassistant/components/tasmota/switch.py +++ b/homeassistant/components/tasmota/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -21,7 +21,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEnt async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota switch dynamically through discovery.""" diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index a031354ae7d..41089016fac 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -4,15 +4,13 @@ from __future__ import annotations from pytautulli import PyTautulli, PyTautulliHostConfiguration -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import TautulliDataUpdateCoordinator +from .coordinator import TautulliConfigEntry, TautulliDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type TautulliConfigEntry = ConfigEntry[TautulliDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> bool: @@ -27,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) entry.runtime_data = TautulliDataUpdateCoordinator( - hass, host_configuration, api_client + hass, entry, host_configuration, api_client ) await entry.runtime_data.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tautulli/coordinator.py b/homeassistant/components/tautulli/coordinator.py index f392ab8df03..5d0f26b83b6 100644 --- a/homeassistant/components/tautulli/coordinator.py +++ b/homeassistant/components/tautulli/coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING from pytautulli import ( PyTautulli, @@ -18,14 +17,14 @@ from pytautulli.exceptions import ( ) from pytautulli.models.host_configuration import PyTautulliHostConfiguration +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import TautulliConfigEntry +type TautulliConfigEntry = ConfigEntry[TautulliDataUpdateCoordinator] class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): @@ -36,6 +35,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, + config_entry: TautulliConfigEntry, host_configuration: PyTautulliHostConfiguration, api_client: PyTautulli, ) -> None: @@ -43,6 +43,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index cd21630031a..c8d35623c21 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -23,12 +23,14 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import TautulliConfigEntry from .const import ATTR_TOP_USER, DOMAIN -from .coordinator import TautulliDataUpdateCoordinator +from .coordinator import TautulliConfigEntry, TautulliDataUpdateCoordinator from .entity import TautulliEntity @@ -213,7 +215,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: TautulliConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tautulli sensor.""" data = entry.runtime_data diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index a89cd999ddd..1263effa96b 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_BUFFER_SIZE, diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index b886dabc80c..df4fc7713aa 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -2,16 +2,13 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] -TechnoVEConfigEntry = ConfigEntry[TechnoVEDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: TechnoVEConfigEntry) -> bool: """Set up TechnoVE from a config entry.""" @@ -25,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TechnoVEConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TechnoVEConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index f231e206c96..ac52a19884e 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -14,10 +14,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TechnoVEConfigEntry -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity @@ -66,7 +65,7 @@ BINARY_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" async_add_entities( diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index 0e4f026ba5c..7ad9829b631 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -5,10 +5,11 @@ from typing import Any from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError import voluptuous as vol -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -49,7 +50,7 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the device with provided mac is already configured diff --git a/homeassistant/components/technove/coordinator.py b/homeassistant/components/technove/coordinator.py index 8527c6e543a..53108463301 100644 --- a/homeassistant/components/technove/coordinator.py +++ b/homeassistant/components/technove/coordinator.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from technove import Station as TechnoVEStation, TechnoVE, TechnoVEError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -13,22 +12,24 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL -if TYPE_CHECKING: - from . import TechnoVEConfigEntry +type TechnoVEConfigEntry = ConfigEntry[TechnoVEDataUpdateCoordinator] class TechnoVEDataUpdateCoordinator(DataUpdateCoordinator[TechnoVEStation]): """Class to manage fetching TechnoVE data from single endpoint.""" - def __init__(self, hass: HomeAssistant, entry: TechnoVEConfigEntry) -> None: + config_entry: TechnoVEConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: TechnoVEConfigEntry) -> None: """Initialize global TechnoVE data updater.""" self.technove = TechnoVE( - entry.data[CONF_HOST], + config_entry.data[CONF_HOST], session=async_get_clientsession(hass), ) super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/technove/diagnostics.py b/homeassistant/components/technove/diagnostics.py index f070d58ab6f..7ac0f6f44fd 100644 --- a/homeassistant/components/technove/diagnostics.py +++ b/homeassistant/components/technove/diagnostics.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import TechnoVEConfigEntry +from .coordinator import TechnoVEConfigEntry TO_REDACT = {"unique_id", "mac_address"} diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 722aa4004e1..746c2280aaa 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.3.1"], + "requirements": ["python-technove==2.0.0"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py index a1cf094c6bf..11d8f281276 100644 --- a/homeassistant/components/technove/number.py +++ b/homeassistant/components/technove/number.py @@ -17,11 +17,10 @@ from homeassistant.components.number import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TechnoVEConfigEntry from .const import DOMAIN -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity from .helpers import technove_exception_handler @@ -66,7 +65,7 @@ NUMBERS = [ async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TechnoVE number entity based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py index e16ac23f89c..398c1911cd4 100644 --- a/homeassistant/components/technove/sensor.py +++ b/homeassistant/components/technove/sensor.py @@ -21,11 +21,10 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import TechnoVEConfigEntry -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity STATUS_TYPE = [s.value for s in Status if s != Status.UNKNOWN] @@ -122,7 +121,7 @@ SENSORS: tuple[TechnoVESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" async_add_entities( diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 9976f0b3c59..05260845a03 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -70,7 +70,7 @@ "plugged_waiting": "Plugged, waiting", "plugged_charging": "Plugged, charging", "out_of_activation_period": "Out of activation period", - "high_charge_period": "High charge period" + "high_tariff_period": "High tariff period" } } }, diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py index a8ad7581da5..19688075b35 100644 --- a/homeassistant/components/technove/switch.py +++ b/homeassistant/components/technove/switch.py @@ -12,11 +12,10 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TechnoVEConfigEntry from .const import DOMAIN -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity from .helpers import technove_exception_handler @@ -80,7 +79,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TechnoVE switch based on a config entry.""" diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 94d3f0b6831..a01b889ef8f 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity @@ -64,25 +64,20 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TedeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeBinarySensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 4012b6d07c5..fec59d1c596 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN @@ -60,7 +60,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() - self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.new_lock_callbacks: list[Callable[[list[TedeeLock]], None]] = [] self.tedee_webhook_id: int | None = None async def _async_setup(self) -> None: @@ -158,8 +158,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): # add new locks if new_locks := current_locks - self._locks_last_update: _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) - for lock_id in new_locks: - for callback in self.new_lock_callbacks: - callback(lock_id) + for callback in self.new_lock_callbacks: + callback([self.data[lock_id] for lock_id in new_locks]) self._locks_last_update = current_locks diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 38df85a9cdb..da6db242db3 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -7,7 +7,7 @@ from aiotedee import TedeeClientException, TedeeLock, TedeeLockState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TedeeApiCoordinator, TedeeConfigEntry @@ -19,28 +19,23 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: TedeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tedee lock entity.""" coordinator = entry.runtime_data - entities: list[TedeeLockEntity] = [] - for lock in coordinator.data.values(): - if lock.is_enabled_pullspring: - entities.append(TedeeLockWithLatchEntity(lock, coordinator)) - else: - entities.append(TedeeLockEntity(lock, coordinator)) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] - if lock.is_enabled_pullspring: - async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)]) - else: - async_add_entities([TedeeLockEntity(lock, coordinator)]) + def _async_add_new_lock(locks: list[TedeeLock]) -> None: + entities: list[TedeeLockEntity] = [] + for lock in locks: + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + async_add_entities(entities) coordinator.new_lock_callbacks.append(_async_add_new_lock) - async_add_entities(entities) + _async_add_new_lock(list(coordinator.data.values())) class TedeeLockEntity(TedeeEntity, LockEntity): diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index d61e7360dc4..a697d36be50 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity @@ -53,25 +53,20 @@ ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TedeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data - async_add_entities( - TedeeSensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - for entity_description in ENTITIES - ) - - def _async_add_new_lock(lock_id: int) -> None: - lock = coordinator.data[lock_id] + def _async_add_new_lock(locks: list[TedeeLock]) -> None: async_add_entities( TedeeSensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES + for lock in locks ) coordinator.new_lock_callbacks.append(_async_add_new_lock) + _async_add_new_lock(list(coordinator.data.values())) class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 78cacd706d3..c7204b6d2a9 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Setup your tedee locks", + "title": "Set up your tedee locks", "data": { "local_access_token": "Local access token", "host": "[%key:common::config_flow::data::host%]" @@ -14,7 +14,7 @@ }, "reauth_confirm": { "title": "Update of access key required", - "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.", + "description": "Tedee needs an updated access key because the existing one is invalid or might have expired.", "data": { "local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]" }, @@ -23,7 +23,7 @@ } }, "reconfigure": { - "title": "Reconfigure Tedee", + "title": "Reconfigure tedee", "description": "Update the settings of this integration.", "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b9a032d7f28..b3c09049ae5 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -36,7 +36,13 @@ from homeassistant.const import ( HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.core import ( + Context, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration @@ -169,6 +175,7 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, vol.Optional(ATTR_TIMEOUT): cv.positive_int, vol.Optional(ATTR_MESSAGE_TAG): cv.string, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), }, extra=vol.ALLOW_EXTRA, ) @@ -210,6 +217,7 @@ SERVICE_SCHEMA_SEND_POLL = vol.Schema( vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_TIMEOUT): cv.positive_int, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), } ) @@ -398,15 +406,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, bot, p_config.get(CONF_ALLOWED_CHAT_IDS), p_config.get(ATTR_PARSER) ) - async def async_send_telegram_message(service: ServiceCall) -> None: + async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse: """Handle sending Telegram Bot message service calls.""" msgtype = service.service kwargs = dict(service.data) _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) + messages = None if msgtype == SERVICE_SEND_MESSAGE: - await notify_service.send_message(context=service.context, **kwargs) + messages = await notify_service.send_message( + context=service.context, **kwargs + ) elif msgtype in [ SERVICE_SEND_PHOTO, SERVICE_SEND_ANIMATION, @@ -414,13 +425,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SEND_VOICE, SERVICE_SEND_DOCUMENT, ]: - await notify_service.send_file(msgtype, context=service.context, **kwargs) + messages = await notify_service.send_file( + msgtype, context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_STICKER: - await notify_service.send_sticker(context=service.context, **kwargs) + messages = await notify_service.send_sticker( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_LOCATION: - await notify_service.send_location(context=service.context, **kwargs) + messages = await notify_service.send_location( + context=service.context, **kwargs + ) elif msgtype == SERVICE_SEND_POLL: - await notify_service.send_poll(context=service.context, **kwargs) + messages = await notify_service.send_poll(context=service.context, **kwargs) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: await notify_service.answer_callback_query( context=service.context, **kwargs @@ -432,10 +449,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) + if service.return_response and messages: + return { + "chats": [ + {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() + ] + } + return None + # Register notification services for service_notif, schema in SERVICE_MAP.items(): + supports_response = SupportsResponse.NONE + + if service_notif in [ + SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_STICKER, + SERVICE_SEND_LOCATION, + SERVICE_SEND_POLL, + ]: + supports_response = SupportsResponse.OPTIONAL + hass.services.async_register( - DOMAIN, service_notif, async_send_telegram_message, schema=schema + DOMAIN, + service_notif, + async_send_telegram_message, + schema=schema, + supports_response=supports_response, ) return True @@ -694,9 +738,10 @@ class TelegramNotificationService: title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) - await self._send_msg( + msg = await self._send_msg( self.bot.send_message, "Error sending message", params[ATTR_MESSAGE_TAG], @@ -711,6 +756,9 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + if msg is not None: + msg_ids[chat_id] = msg.id + return msg_ids async def delete_message(self, chat_id=None, context=None, **kwargs): """Delete a previously sent message.""" @@ -829,12 +877,13 @@ class TelegramNotificationService: ), ) + msg_ids = {} if file_content: for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_photo, "Error sending photo", params[ATTR_MESSAGE_TAG], @@ -851,7 +900,7 @@ class TelegramNotificationService: ) elif file_type == SERVICE_SEND_STICKER: - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -866,7 +915,7 @@ class TelegramNotificationService: ) elif file_type == SERVICE_SEND_VIDEO: - await self._send_msg( + msg = await self._send_msg( self.bot.send_video, "Error sending video", params[ATTR_MESSAGE_TAG], @@ -882,7 +931,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_DOCUMENT: - await self._send_msg( + msg = await self._send_msg( self.bot.send_document, "Error sending document", params[ATTR_MESSAGE_TAG], @@ -898,7 +947,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_VOICE: - await self._send_msg( + msg = await self._send_msg( self.bot.send_voice, "Error sending voice", params[ATTR_MESSAGE_TAG], @@ -913,7 +962,7 @@ class TelegramNotificationService: context=context, ) elif file_type == SERVICE_SEND_ANIMATION: - await self._send_msg( + msg = await self._send_msg( self.bot.send_animation, "Error sending animation", params[ATTR_MESSAGE_TAG], @@ -929,17 +978,22 @@ class TelegramNotificationService: context=context, ) + msg_ids[chat_id] = msg.id file_content.seek(0) else: _LOGGER.error("Can't send file with kwargs: %s", kwargs) - async def send_sticker(self, target=None, context=None, **kwargs): + return msg_ids + + async def send_sticker(self, target=None, context=None, **kwargs) -> dict: """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) + + msg_ids = {} if stickerid: for chat_id in self._get_target_chat_ids(target): - await self._send_msg( + msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], @@ -952,8 +1006,9 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) - else: - await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) + msg_ids[chat_id] = msg.id + return msg_ids + return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) async def send_location( self, latitude, longitude, target=None, context=None, **kwargs @@ -962,11 +1017,12 @@ class TelegramNotificationService: latitude = float(latitude) longitude = float(longitude) params = self._get_msg_kwargs(kwargs) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) - await self._send_msg( + msg = await self._send_msg( self.bot.send_location, "Error sending location", params[ATTR_MESSAGE_TAG], @@ -979,6 +1035,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def send_poll( self, @@ -993,9 +1051,10 @@ class TelegramNotificationService: """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) + msg_ids = {} for chat_id in self._get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) - await self._send_msg( + msg = await self._send_msg( self.bot.send_poll, "Error sending poll", params[ATTR_MESSAGE_TAG], @@ -1011,6 +1070,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) + msg_ids[chat_id] = msg.id + return msg_ids async def leave_chat(self, chat_id=None, context=None): """Remove bot from chat.""" @@ -1070,6 +1131,7 @@ class BaseTelegramBotEntity: ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, ATTR_DATE: message.date, + ATTR_MESSAGE_THREAD_ID: message.message_thread_id, } if filters.COMMAND.filter(message): # This is a command message - set event type to command and split data into command and args diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 1a02543d4ab..8f4894f42a7 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -14,7 +14,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the notification to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "Parse mode", @@ -30,7 +30,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send message. Will help with timeout errors (poor internet connection, etc)s." + "description": "Timeout for sending the message in seconds. Will help with timeout errors (poor Internet connection, etc)." }, "keyboard": { "name": "Keyboard", @@ -45,11 +45,11 @@ "description": "Tag for sent message." }, "reply_to_message_id": { - "name": "Reply to message id", + "name": "Reply to message ID", "description": "Mark the message as a reply to a previous message." }, "message_thread_id": { - "name": "Message thread id", + "name": "Message thread ID", "description": "Unique identifier for the target message thread (topic) of the forum; for forum supergroups only." } } @@ -84,7 +84,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the document to. If not present, first allowed chat ID is the default." }, "parse_mode": { "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", @@ -96,11 +96,11 @@ }, "verify_ssl": { "name": "Verify SSL", - "description": "Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." + "description": "Enable or disable SSL certificate verification. Disable if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send photo." + "description": "Timeout for sending the photo in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -166,7 +166,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send sticker." + "description": "Timeout for sending the sticker in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -306,7 +306,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send video." + "description": "Timeout for sending the video in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -372,7 +372,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send voice." + "description": "Timeout for sending the voice in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -442,7 +442,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send document." + "description": "Timeout for sending the document in seconds." }, "keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", @@ -480,7 +480,7 @@ }, "target": { "name": "Target", - "description": "An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default." + "description": "An array of pre-authorized chat IDs to send the location to. If not present, first allowed chat ID is the default." }, "disable_notification": { "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", @@ -530,11 +530,11 @@ }, "is_anonymous": { "name": "Is anonymous", - "description": "If the poll needs to be anonymous, defaults to True." + "description": "If the poll needs to be anonymous. This is the default." }, "allows_multiple_answers": { "name": "Allow multiple answers", - "description": "If the poll allows multiple answers, defaults to False." + "description": "If the poll allows multiple answers." }, "open_period": { "name": "Open period", @@ -546,7 +546,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for send poll." + "description": "Timeout for sending the poll in seconds." }, "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", @@ -568,11 +568,11 @@ "fields": { "message_id": { "name": "Message ID", - "description": "Id of the message to edit." + "description": "ID of the message to edit." }, "chat_id": { "name": "Chat ID", - "description": "The chat_id where to edit the message." + "description": "ID of the chat where to edit the message." }, "message": { "name": "Message", @@ -606,7 +606,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the caption." + "description": "ID of the chat where to edit the caption." }, "caption": { "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", @@ -620,7 +620,7 @@ }, "edit_replymarkup": { "name": "Edit reply markup", - "description": "Edit the inline keyboard of a previously sent message.", + "description": "Edits the inline keyboard of a previously sent message.", "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", @@ -628,7 +628,7 @@ }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to edit the reply_markup." + "description": "ID of the chat where to edit the reply markup." }, "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", @@ -646,7 +646,7 @@ }, "callback_query_id": { "name": "Callback query ID", - "description": "Unique id of the callback response." + "description": "Unique ID of the callback response." }, "show_alert": { "name": "Show alert", @@ -654,7 +654,7 @@ }, "timeout": { "name": "Read timeout", - "description": "Read timeout for sending the answer." + "description": "Timeout for sending the answer in seconds." } } }, @@ -664,11 +664,11 @@ "fields": { "message_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", - "description": "Id of the message to delete." + "description": "ID of the message to delete." }, "chat_id": { "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", - "description": "The chat_id where to delete the message." + "description": "ID of the chat where to delete the message." } } } diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 3eb3c71a0bb..9bd360f5e41 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -109,13 +109,12 @@ class PushBot(BaseTelegramBotEntity): else: _LOGGER.debug("telegram webhook status: %s", current_status) - if current_status and current_status["url"] != self.webhook_url: - result = await self._try_to_set_webhook() - if result: - _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) - else: - _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) - return False + result = await self._try_to_set_webhook() + if result: + _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) + else: + _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) + return False return True diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py index 33f936beb54..65301708646 100644 --- a/homeassistant/components/tellduslive/binary_sensor.py +++ b/homeassistant/components/tellduslive/binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -14,7 +14,7 @@ from .entity import TelldusLiveEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index d55a72cd633..2554acc428c 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -7,7 +7,7 @@ from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelldusLiveClient from .const import DOMAIN, TELLDUS_DISCOVERY_NEW @@ -17,7 +17,7 @@ from .entity import TelldusLiveEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py index a71fcb685c0..5366e4c27df 100644 --- a/homeassistant/components/tellduslive/entity.py +++ b/homeassistant/components/tellduslive/entity.py @@ -33,7 +33,7 @@ class TelldusLiveEntity(Entity): self._id = device_id self._client = client - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" _LOGGER.debug("Created device %s", self) self.async_on_remove( @@ -58,12 +58,12 @@ class TelldusLiveEntity(Entity): return self.device.state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" return True @property - def available(self): + def available(self) -> bool: """Return true if device is not offline.""" return self._client.is_available(self.device_id) diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 005bf97d8c0..9f291bb845a 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -8,7 +8,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 9bd2b1fe599..782f240cc41 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -121,7 +121,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index e363aced667..b0750a7785d 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,15 +11,15 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate against TelldusLive" + "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", + "title": "Authenticate with TelldusLive" }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for local API." } } } diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index bd770ab08f5..3ca2ba066ab 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -16,7 +16,7 @@ from .entity import TelldusLiveEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 9d120b7aaa8..6ccc1f14b5f 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py index 746c7f4dd4d..5be3d1f48f4 100644 --- a/homeassistant/components/tellstick/entity.py +++ b/homeassistant/components/tellstick/entity.py @@ -40,7 +40,7 @@ class TellstickDevice(Entity): self._attr_name = tellcore_device.name self._attr_unique_id = tellcore_device.id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -146,6 +146,6 @@ class TellstickDevice(Entity): except TelldusError as err: _LOGGER.error(err) - def update(self): + def update(self) -> None: """Poll the current state of the device.""" self._update_from_tellcore() diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 1e27511bd84..c777aa6f01f 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 82d8905a775..0fa1076c943 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -4,9 +4,9 @@ from __future__ import annotations from datetime import timedelta import logging -import telnetlib # pylint: disable=deprecated-module from typing import Any +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.switch import ( @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 390a4a31bdb..15a73cf3de5 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -53,6 +53,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload top-level + platforms.""" + await async_get_blueprints(hass).async_reset_cache() try: unprocessed_conf = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: @@ -93,7 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue if isinstance(entry.options[key], str): raise ConfigEntryError( - f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to " + f"The '{entry.options.get(CONF_NAME) or ''}' number template needs to " f"be reconfigured, {key} must be a number, got '{entry.options[key]}'" ) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index aa1f99f0423..0a468994295 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -28,11 +28,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -147,7 +149,7 @@ async def _async_create_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 922f1d88ffb..7ef64e8077b 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -40,11 +40,13 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_call_later, async_track_point_in_utc_time from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -151,7 +153,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( @callback def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, hass: HomeAssistant, definitions: list[dict], unique_id_prefix: str | None, @@ -210,7 +212,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 67ce7e7a16b..f43fc242bba 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -19,7 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -93,7 +96,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 2642ede9c3a..306b4405c6a 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 7720ef7e1b3..6ed525fd45f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index ba85418c339..5afbca55cbb 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -96,7 +99,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 9391e368e2b..206703ddcce 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1013,7 +1013,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= LightEntityFeature.EFFECT + self._attr_supported_features &= ~LightEntityFeature.TRANSITION self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index f194154a50c..0804f92e46d 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 90dd555ca42..661dbb45dc1 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -27,7 +27,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -121,7 +124,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index bd37ca1015c..a42ee3d0612 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -24,7 +24,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -115,7 +118,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index ee24407699d..ca3736ebf76 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -44,7 +44,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -178,7 +181,7 @@ _LOGGER = logging.getLogger(__name__) @callback def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, hass: HomeAssistant, definitions: list[dict], unique_id_prefix: str | None, @@ -237,7 +240,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index bddb51e5e67..756866cfd44 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -28,7 +28,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -104,7 +107,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f5b84b1ad7a..8f9edca5976 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -8,7 +8,7 @@ import itertools import logging from typing import Any, cast -from propcache import under_cached_property +from propcache.api import under_cached_property import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT @@ -33,7 +33,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 19029cc708b..b977f4e659a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f4a3a7bfe07..15addd3513d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 1cd856f31d0..81705e326f7 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.2.1", + "numpy==2.2.2", "Pillow==11.1.0" ] } diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index ff50a99748e..27bfb9134ab 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -25,17 +25,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -139,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - api = VehicleSigned(tesla.vehicle, vin) else: api = VehicleSpecific(tesla.vehicle, vin) - coordinator = TeslaFleetVehicleDataCoordinator(hass, api, product) + coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product) await coordinator.async_config_entry_first_refresh() @@ -175,10 +175,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - api = EnergySpecific(tesla.energy, site_id) - live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api) - info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product) + live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api) + history_coordinator = TeslaFleetEnergySiteHistoryCoordinator( + hass, entry, api + ) + info_coordinator = TeslaFleetEnergySiteInfoCoordinator( + hass, entry, api, product + ) await live_coordinator.async_config_entry_first_refresh() + await history_coordinator.async_config_entry_first_refresh() await info_coordinator.async_config_entry_first_refresh() # Create energy site model @@ -211,6 +217,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - TeslaFleetEnergyData( api=api, live_coordinator=live_coordinator, + history_coordinator=history_coordinator, info_coordinator=info_coordinator, id=site_id, device=device, diff --git a/homeassistant/components/tesla_fleet/binary_sensor.py b/homeassistant/components/tesla_fleet/binary_sensor.py index b92ef9233d1..886fe304c91 100644 --- a/homeassistant/components/tesla_fleet/binary_sensor.py +++ b/homeassistant/components/tesla_fleet/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry @@ -179,7 +179,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet binary sensor platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index aea0f91a97c..2ddce2d517b 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetVehicleEntity @@ -61,7 +61,7 @@ DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet Button platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 06e9c9d7c64..f752509ee17 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .const import DOMAIN, TeslaFleetClimateSide @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet Climate platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 9b3baf49bfb..5d2dc84c49e 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -37,6 +37,30 @@ MODELS = { "T": "Tesla Semi", } +ENERGY_HISTORY_FIELDS = [ + "solar_energy_exported", + "generator_energy_exported", + "grid_energy_imported", + "grid_services_energy_imported", + "grid_services_energy_exported", + "grid_energy_exported_from_solar", + "grid_energy_exported_from_generator", + "grid_energy_exported_from_battery", + "battery_energy_exported", + "battery_energy_imported_from_grid", + "battery_energy_imported_from_solar", + "battery_energy_imported_from_generator", + "consumer_energy_imported_from_grid", + "consumer_energy_imported_from_solar", + "consumer_energy_imported_from_battery", + "consumer_energy_imported_from_generator", + "total_home_usage", + "total_battery_charge", + "total_battery_discharge", + "total_solar_generation", + "total_grid_energy_exported", +] + class TeslaFleetState(StrEnum): """Teslemetry Vehicle States.""" diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 42b93352a6f..128c15068f6 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -1,10 +1,14 @@ """Tesla Fleet Data Coordinator.""" +from __future__ import annotations + from datetime import datetime, timedelta -from typing import Any +from random import randint +from time import time +from typing import TYPE_CHECKING, Any from tesla_fleet_api import EnergySpecific, VehicleSpecific -from tesla_fleet_api.const import VehicleDataEndpoint +from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, LoginRequired, @@ -13,20 +17,23 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) -from tesla_fleet_api.ratecalculator import RateCalculator from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER, TeslaFleetState +if TYPE_CHECKING: + from . import TeslaFleetConfigEntry -VEHICLE_INTERVAL_SECONDS = 90 +from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState + +VEHICLE_INTERVAL_SECONDS = 300 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_INTERVAL_SECONDS = 60 ENERGY_INTERVAL = timedelta(seconds=ENERGY_INTERVAL_SECONDS) +ENERGY_HISTORY_INTERVAL = timedelta(minutes=5) ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, @@ -54,18 +61,23 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the TeslaFleet API.""" + config_entry: TeslaFleetConfigEntry updated_once: bool pre2021: bool last_active: datetime - rate: RateCalculator def __init__( - self, hass: HomeAssistant, api: VehicleSpecific, product: dict + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: VehicleSpecific, + product: dict, ) -> None: """Initialize TeslaFleet Vehicle Update Coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Tesla Fleet Vehicle", update_interval=VEHICLE_INTERVAL, ) @@ -73,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(200, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" try: - # Check if the vehicle is awake using a non-rate limited API call - if self.data["state"] != TeslaFleetState.ONLINE: - response = await self.api.vehicle() - self.data["state"] = response["response"]["state"] + # Check if the vehicle is awake using a free API call + response = await self.api.vehicle() + self.data["state"] = response["response"]["state"] if self.data["state"] != TeslaFleetState.ONLINE: return self.data - # This is a rated limited API call - self.rate.consume() response = await self.api.vehicle_data(endpoints=ENDPOINTS) data = response["response"] except VehicleOffline: self.data["state"] = TeslaFleetState.ASLEEP return self.data - except RateLimited as e: + except RateLimited: LOGGER.warning( - "%s rate limited, will retry in %s seconds", + "%s rate limited, will skip refresh", self.name, - e.data.get("after"), ) - if "after" in e.data: - self.update_interval = timedelta(seconds=int(e.data["after"])) return self.data except (InvalidToken, OAuthExpired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - # Calculate ideal refresh interval - self.update_interval = timedelta(seconds=self.rate.calculate()) + self.update_interval = VEHICLE_INTERVAL self.updated_once = True @@ -138,13 +142,20 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the TeslaFleet API.""" + config_entry: TeslaFleetConfigEntry updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: EnergySpecific, + ) -> None: """Initialize TeslaFleet Energy Site Live coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Tesla Fleet Energy Site Live", update_interval=timedelta(seconds=10), ) @@ -182,16 +193,87 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) return data +class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site history import and export from the Tesla Fleet API.""" + + config_entry: TeslaFleetConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: EnergySpecific, + ) -> None: + """Initialize Tesla Fleet Energy Site History coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"Tesla Fleet Energy History {api.energy_site_id}", + update_interval=timedelta(seconds=300), + ) + self.api = api + self.data = {} + self.updated_once = False + + async def async_config_entry_first_refresh(self) -> None: + """Set up the data coordinator.""" + await super().async_config_entry_first_refresh() + + # Calculate seconds until next 5 minute period plus a random delay + delta = randint(310, 330) - (int(time()) % 300) + self.logger.debug("Scheduling next %s refresh in %s seconds", self.name, delta) + self.update_interval = timedelta(seconds=delta) + self._schedule_refresh() + self.update_interval = ENERGY_HISTORY_INTERVAL + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site history data using Tesla Fleet API.""" + + try: + data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] + except RateLimited as e: + LOGGER.warning( + "%s rate limited, will retry in %s seconds", + self.name, + e.data.get("after"), + ) + if "after" in e.data: + self.update_interval = timedelta(seconds=int(e.data["after"])) + return self.data + except (InvalidToken, OAuthExpired, LoginRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + self.updated_once = True + + # Add all time periods together + output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + for period in data.get("time_series", []): + for key in ENERGY_HISTORY_FIELDS: + output[key] += period.get(key, 0) + + return output + + class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the TeslaFleet API.""" + config_entry: TeslaFleetConfigEntry updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: EnergySpecific, + product: dict, + ) -> None: """Initialize TeslaFleet Energy Info coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Tesla Fleet Energy Site Info", update_interval=timedelta(seconds=15), ) diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index f270734424f..701b107f9f9 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetVehicleEntity @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet cover platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index d6dcef895a6..19bf353c62d 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .entity import TeslaFleetVehicleEntity @@ -14,7 +14,9 @@ from .models import TeslaFleetVehicleData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet device tracker platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0ee41b5e322..0260acf368e 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -24,6 +25,7 @@ class TeslaFleetEntity( CoordinatorEntity[ TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator ] ): @@ -37,6 +39,7 @@ class TeslaFleetEntity( self, coordinator: TeslaFleetVehicleDataCoordinator | TeslaFleetEnergySiteLiveCoordinator + | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, api: VehicleSpecific | EnergySpecific, key: str, @@ -139,6 +142,21 @@ class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): super().__init__(data.live_coordinator, data.api, key) +class TeslaFleetEnergyHistoryEntity(TeslaFleetEntity): + """Parent class for TeslaFleet Energy Site History entities.""" + + def __init__( + self, + data: TeslaFleetEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Tesla Fleet Energy Site History entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(data.history_coordinator, data.api, key) + + class TeslaFleetEnergyInfoEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Info entities.""" diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 449dda93c62..f907107fd40 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -232,10 +232,73 @@ "island_status_unknown": "mdi:help-circle", "off_grid_intentional": "mdi:account-cancel" } + }, + "total_home_usage": { + "default": "mdi:home-lightning-bolt" + }, + "total_battery_charge": { + "default": "mdi:battery-arrow-up" + }, + "total_battery_discharge": { + "default": "mdi:battery-arrow-down" + }, + "total_solar_production": { + "default": "mdi:solar-power-variant" + }, + "grid_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "total_grid_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "solar_energy_exported": { + "default": "mdi:solar-power-variant" + }, + "generator_energy_exported": { + "default": "mdi:generator-stationary" + }, + "grid_services_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "grid_services_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "grid_energy_exported_from_solar": { + "default": "mdi:solar-power" + }, + "grid_energy_exported_from_generator": { + "default": "mdi:generator-stationary" + }, + "grid_energy_exported_from_battery": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_exported": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "battery_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "battery_energy_imported_from_generator": { + "default": "mdi:generator-stationary" + }, + "consumer_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "consumer_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "consumer_energy_imported_from_battery": { + "default": "mdi:home-battery" + }, + "consumer_energy_imported_from_generator": { + "default": "mdi:generator-stationary" } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/lock.py b/homeassistant/components/tesla_fleet/lock.py index 32998d409be..cdb1d4b066b 100644 --- a/homeassistant/components/tesla_fleet/lock.py +++ b/homeassistant/components/tesla_fleet/lock.py @@ -9,7 +9,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .const import DOMAIN @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet lock platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index aecc6a04af3..53aff3d0a54 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2"] + "requirements": ["tesla-fleet-api==0.9.12"] } diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 455c990077d..89f0768f082 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetVehicleEntity @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet Media platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index ae945dd96bf..469ebdca914 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -11,6 +11,7 @@ from tesla_fleet_api.const import Scope from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( + TeslaFleetEnergySiteHistoryCoordinator, TeslaFleetEnergySiteInfoCoordinator, TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, @@ -44,6 +45,7 @@ class TeslaFleetEnergyData: api: EnergySpecific live_coordinator: TeslaFleetEnergySiteLiveCoordinator + history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index b806b4dbc77..a1123ab9553 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslaFleetConfigEntry @@ -95,7 +95,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet number platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/select.py b/homeassistant/components/tesla_fleet/select.py index 515a0e7c2e7..1c495657bc1 100644 --- a/homeassistant/components/tesla_fleet/select.py +++ b/homeassistant/components/tesla_fleet/select.py @@ -10,7 +10,7 @@ from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity @@ -78,7 +78,7 @@ SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet select platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index b4e7b51faba..64ecc35469b 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -29,14 +29,15 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance from . import TeslaFleetConfigEntry -from .const import TeslaFleetState +from .const import ENERGY_HISTORY_FIELDS, TeslaFleetState from .entity import ( + TeslaFleetEnergyHistoryEntity, TeslaFleetEnergyInfoEntity, TeslaFleetEnergyLiveEntity, TeslaFleetVehicleEntity, @@ -302,8 +303,8 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslaFleetTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = ( + TeslaFleetSensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -311,7 +312,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -320,7 +321,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -330,14 +331,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -345,7 +347,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -353,7 +355,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -361,7 +363,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -369,7 +371,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -378,7 +380,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="island_status", options=[ "island_status_unknown", @@ -415,6 +417,21 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) +ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( + SensorEntityDescription( + key=key, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=( + key.startswith("total") or key == "grid_energy_imported" + ), + ) + for key in ENERGY_HISTORY_FIELDS +) + ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="vpp_backup_reserve_percent", @@ -429,7 +446,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet sensor platform from a config entry.""" async_add_entities( @@ -450,6 +467,13 @@ async def async_setup_entry( for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data ), + ( # Add energy site history + TeslaFleetEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + or energysite.info_coordinator.data.get("components_solar") + ), ( # Add wall connectors TeslaFleetWallConnectorSensorEntity(energysite, wc["din"], description) for energysite in entry.runtime_data.energysites @@ -527,6 +551,25 @@ class TeslaFleetVehicleTimeSensorEntity(TeslaFleetVehicleEntity, SensorEntity): class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity): """Base class for Tesla Fleet energy site metric sensors.""" + entity_description: TeslaFleetSensorEntityDescription + + def __init__( + self, + data: TeslaFleetEnergyData, + description: TeslaFleetSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_native_value = self.entity_description.value_fn(self._value) + + +class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): + """Base class for Tesla Fleet energy site metric sensors.""" + entity_description: SensorEntityDescription def __init__( @@ -540,7 +583,6 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = not self.is_none self._attr_native_value = self._value diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index fe5cd06c1ef..331885893fe 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -329,7 +329,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", @@ -424,6 +424,9 @@ "off_grid_intentional": "Disconnected intentionally" } }, + "storm_mode_active": { + "name": "Storm Watch active" + }, "vehicle_state_tpms_pressure_fl": { "name": "Tire pressure front left" }, @@ -453,10 +456,73 @@ }, "wall_connector_state": { "name": "State code" + }, + "solar_energy_exported": { + "name": "Solar exported" + }, + "generator_energy_exported": { + "name": "Generator exported" + }, + "grid_energy_imported": { + "name": "Grid imported" + }, + "grid_services_energy_imported": { + "name": "Grid services imported" + }, + "grid_services_energy_exported": { + "name": "Grid services exported" + }, + "grid_energy_exported_from_solar": { + "name": "Grid exported from solar" + }, + "grid_energy_exported_from_generator": { + "name": "Grid exported from generator" + }, + "grid_energy_exported_from_battery": { + "name": "Grid exported from battery" + }, + "battery_energy_exported": { + "name": "Battery exported" + }, + "battery_energy_imported_from_grid": { + "name": "Battery imported from grid" + }, + "battery_energy_imported_from_solar": { + "name": "Battery imported from solar" + }, + "battery_energy_imported_from_generator": { + "name": "Battery imported from generator" + }, + "consumer_energy_imported_from_grid": { + "name": "Consumer imported from grid" + }, + "consumer_energy_imported_from_solar": { + "name": "Consumer imported from solar" + }, + "consumer_energy_imported_from_battery": { + "name": "Consumer imported from battery" + }, + "consumer_energy_imported_from_generator": { + "name": "Consumer imported from generator" + }, + "total_home_usage": { + "name": "Home usage" + }, + "total_battery_charge": { + "name": "Battery charged" + }, + "total_battery_discharge": { + "name": "Battery discharged" + }, + "total_solar_generation": { + "name": "Solar generated" + }, + "total_grid_energy_exported": { + "name": "Grid exported" } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index d602cff78c0..614af8772cc 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -15,7 +15,8 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity @@ -32,6 +33,8 @@ class TeslaFleetSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( @@ -77,20 +80,21 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslaFleetSwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + TeslaFleetSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), ) async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet Switch platform from a config entry.""" @@ -103,12 +107,6 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( - TeslaFleetChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslaFleetChargeFromGridSwitchEntity( energysite, @@ -144,16 +142,18 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" if self._value is None: self._attr_is_on = None else: - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -172,17 +172,6 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt self.async_write_ha_state() -class TeslaFleetChargeSwitchEntity(TeslaFleetVehicleSwitchEntity): - """Entity class for TeslaFleet charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslaFleetChargeFromGridSwitchEntity( TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity ): diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index f7ef385b8ed..6d60162412e 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS @@ -48,7 +48,7 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index 3296539f701..d100b1e5549 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -9,11 +9,11 @@ from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER @@ -48,7 +48,7 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery.""" self.ip_address = discovery_info.ip diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index a50c81c912e..c6c63a93edb 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS @@ -187,7 +187,7 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 1a03207a012..b356a9f3ebc 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -42,7 +42,7 @@ "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", - "charging": "Charging" + "charging": "[%key:common::state::charging%]" } }, "status_code": { diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 5779283b955..eef974cc5a7 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -7,6 +7,7 @@ from typing import Final from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( + Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, @@ -17,9 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType @@ -112,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - product.pop("cached_data", None) vin = product["vin"] api = VehicleSpecific(teslemetry.vehicle, vin) - coordinator = TeslemetryVehicleDataCoordinator(hass, api, product) + coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product) device = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Tesla", @@ -126,13 +126,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - create_handle_vehicle_stream(vin, coordinator), {"vin": vin}, ) + firmware = vehicle_metadata[vin].get("firmware", "Unknown") + stream_vehicle = stream.get_vehicle(vin) vehicles.append( TeslemetryVehicleData( api=api, + config_entry=entry, coordinator=coordinator, stream=stream, + stream_vehicle=stream_vehicle, vin=vin, + firmware=firmware, device=device, remove_listener=remove_listener, ) @@ -160,15 +165,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - serial_number=str(site_id), ) + # Check live status endpoint works before creating its coordinator + try: + live_status = (await api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise ConfigEntryNotReady(e.message) from e + energysites.append( TeslemetryEnergyData( api=api, - live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api), + live_coordinator=( + TeslemetryEnergySiteLiveCoordinator( + hass, entry, api, live_status + ) + if isinstance(live_status, dict) + else None + ), info_coordinator=TeslemetryEnergySiteInfoCoordinator( - hass, api, product + hass, entry, api, product ), history_coordinator=( - TeslemetryEnergyHistoryCoordinator(hass, api) + TeslemetryEnergyHistoryCoordinator(hass, entry, api) if powerwall else None ), @@ -179,14 +198,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Run all first refreshes await asyncio.gather( + *(async_setup_stream(hass, entry, vehicle) for vehicle in vehicles), *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles ), - *( - energysite.live_coordinator.async_config_entry_first_refresh() - for energysite in energysites - ), *( energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites @@ -228,7 +244,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TeslemetryConfigEntry +) -> bool: """Migrate config entry.""" if config_entry.version > 1: return False @@ -265,3 +283,16 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None coordinator.async_set_updated_data(coordinator.data) return handle_vehicle_stream + + +async def async_setup_stream( + hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData +): + """Set up the stream for a vehicle.""" + + await vehicle.stream_vehicle.get_config() + entry.async_create_background_task( + hass, + vehicle.stream_vehicle.prefer_typed(True), + f"Prefer typed for {vehicle.vin}", + ) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 29ebfea4db1..9d14df4501b 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -4,17 +4,20 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import cast +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry @@ -23,6 +26,7 @@ from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,133 +37,327 @@ PARALLEL_UPDATES = 0 class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Teslemetry binary sensor entity.""" - is_on: Callable[[StateType], bool] = bool + polling_value_fn: Callable[[StateType], bool | None] = bool + polling: bool = False + streaming_key: Signal | None = None + streaming_firmware: str = "2024.26" + streaming_value_fn: Callable[[StateType], bool | None] = ( + lambda x: x is True or x == "true" + ) VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="state", + polling=True, + polling_value_fn=lambda x: x == TeslemetryState.ONLINE, device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on=lambda x: x == TeslemetryState.ONLINE, ), TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", + polling=True, + streaming_key=Signal.BATTERY_HEATER_ON, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", - is_on=lambda x: cast(int, x) > 1, + polling=True, + streaming_key=Signal.CHARGER_PHASES, + polling_value_fn=lambda x: cast(int, x) > 1, + streaming_value_fn=lambda x: cast(int, x) > 1, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", + polling=True, + streaming_key=Signal.PRECONDITIONING_ENABLED, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="climate_state_is_preconditioning", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", + polling=True, + streaming_key=Signal.SCHEDULED_CHARGING_PENDING, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_trip_charging", + polling=True, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_conn_charge_cable", - is_on=lambda x: x != "", + polling=True, + polling_value_fn=lambda x: x != "", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="climate_state_cabin_overheat_protection_actively_cooling", + polling=True, device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dashcam_state", + polling=True, device_class=BinarySensorDeviceClass.RUNNING, - is_on=lambda x: x == "Recording", + polling_value_fn=lambda x: x == "Recording", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_is_user_present", + polling=True, device_class=BinarySensorDeviceClass.PRESENCE, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rl", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_rr", + polling=True, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", + polling=True, + streaming_key=Signal.FD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fp_window", + polling=True, + streaming_key=Signal.FP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rd_window", + polling=True, + streaming_key=Signal.RD_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rp_window", + polling=True, + streaming_key=Signal.RP_WINDOW, + streaming_value_fn=lambda x: WindowState.get(x) != "Closed", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_df", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pf", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pr", + polling=True, device_class=BinarySensorDeviceClass.DOOR, + streaming_key=Signal.DOOR_STATE, + streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"), entity_category=EntityCategory.DIAGNOSTIC, ), + TeslemetryBinarySensorEntityDescription( + key="automatic_blind_spot_camera", + streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="automatic_emergency_braking_off", + streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="blind_spot_collision_warning_chime", + streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="bms_full_charge_complete", + streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="brake_pedal", + streaming_key=Signal.BRAKE_PEDAL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_port_cold_weather_mode", + streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="service_mode", + streaming_key=Signal.SERVICE_MODE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="pin_to_drive_enabled", + streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="drive_rail", + streaming_key=Signal.DRIVE_RAIL, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_belt", + streaming_key=Signal.DRIVER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="driver_seat_occupied", + streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="passenger_seat_belt", + streaming_key=Signal.PASSENGER_SEAT_BELT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="fast_charger_present", + streaming_key=Signal.FAST_CHARGER_PRESENT, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="gps_state", + streaming_key=Signal.GPS_STATE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + TeslemetryBinarySensorEntityDescription( + key="guest_mode_enabled", + streaming_key=Signal.GUEST_MODE_ENABLED, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="dc_dc_enable", + streaming_key=Signal.DC_DC_ENABLE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="emergency_lane_departure_avoidance", + streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="supercharger_session_trip_planner", + streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="wiper_heat_enabled", + streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="rear_display_hvac_enabled", + streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="offroad_lightbar_present", + streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="homelink_nearby", + streaming_key=Signal.HOMELINK_NEARBY, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="europe_vehicle", + streaming_key=Signal.EUROPE_VEHICLE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="right_hand_drive", + streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_firmware="2024.44.25", + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_home", + streaming_key=Signal.LOCATED_AT_HOME, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_work", + streaming_key=Signal.LOCATED_AT_WORK, + streaming_firmware="2024.44.32", + ), + TeslemetryBinarySensorEntityDescription( + key="located_at_favorite", + streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_firmware="2024.44.32", + entity_registry_enabled_default=False, + ), ) ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( @@ -179,34 +377,46 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry binary sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Vehicles - TeslemetryVehicleBinarySensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Energy Site Live - TeslemetryEnergyLiveBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ( # Energy Site Info - TeslemetryEnergyInfoBinarySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - ), - ) + entities: list[BinarySensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append( + TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description) + ) + elif description.polling: + entities.append( + TeslemetryVehiclePollingBinarySensorEntity(vehicle, description) + ) + + entities.extend( + TeslemetryEnergyLiveBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data + ) + entities.extend( + TeslemetryEnergyInfoBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data ) + async_add_entities(entities) -class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorEntity): + +class TeslemetryVehiclePollingBinarySensorEntity( + TeslemetryVehicleEntity, BinarySensorEntity +): """Base class for Teslemetry vehicle binary sensors.""" entity_description: TeslemetryBinarySensorEntityDescription @@ -223,12 +433,40 @@ class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorE def _async_update_attrs(self) -> None: """Update the attributes of the binary sensor.""" - if self._value is None: - self._attr_available = False - self._attr_is_on = None - else: - self._attr_available = True - self._attr_is_on = self.entity_description.is_on(self._value) + self._attr_available = self._value is not None + if self._attr_available: + assert self._value is not None + self._attr_is_on = self.entity_description.polling_value_fn(self._value) + + +class TeslemetryVehicleStreamingBinarySensorEntity( + TeslemetryVehicleStreamEntity, BinarySensorEntity, RestoreEntity +): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryBinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_is_on = state.state == STATE_ON + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + self._attr_available = value is not None + if self._attr_available: + self._attr_is_on = self.entity_description.streaming_value_fn(value) class TeslemetryEnergyLiveBinarySensorEntity( diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index a9bf3eddd6a..4ca2fd9b166 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -10,11 +10,11 @@ from tesla_fleet_api.const import Scope from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity -from .helpers import handle_vehicle_command +from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -24,28 +24,35 @@ PARALLEL_UPDATES = 0 class TeslemetryButtonEntityDescription(ButtonEntityDescription): """Describes a Teslemetry Button entity.""" - func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( - TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup TeslemetryButtonEntityDescription( - key="flash_lights", func=lambda self: self.api.flash_lights() + key="wake", func=lambda self: handle_command(self.api.wake_up()) ), TeslemetryButtonEntityDescription( - key="honk", func=lambda self: self.api.honk_horn() + key="flash_lights", + func=lambda self: handle_vehicle_command(self.api.flash_lights()), ), TeslemetryButtonEntityDescription( - key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + key="honk", func=lambda self: handle_vehicle_command(self.api.honk_horn()) ), TeslemetryButtonEntityDescription( - key="boombox", func=lambda self: self.api.remote_boombox(0) + key="enable_keyless_driving", + func=lambda self: handle_vehicle_command(self.api.remote_start_drive()), + ), + TeslemetryButtonEntityDescription( + key="boombox", + func=lambda self: handle_vehicle_command(self.api.remote_boombox(0)), ), TeslemetryButtonEntityDescription( key="homelink", - func=lambda self: self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], + func=lambda self: handle_vehicle_command( + self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ) ), ), ) @@ -54,7 +61,7 @@ DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Button platform from a config entry.""" @@ -85,6 +92,4 @@ class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.wake_up_if_asleep() - if self.entity_description.func: - await handle_vehicle_command(self.entity_description.func(self)) + await self.entity_description.func(self) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 95b769a1c2d..86811131ab6 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Climate platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 303a3250edf..0cd2a5a62d6 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -1,7 +1,9 @@ """Teslemetry Data Coordinator.""" +from __future__ import annotations + from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint @@ -15,6 +17,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TeslemetryConfigEntry + from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten @@ -37,15 +42,21 @@ ENDPOINTS = [ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" + config_entry: TeslemetryConfigEntry last_active: datetime def __init__( - self, hass: HomeAssistant, api: VehicleSpecific, product: dict + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: VehicleSpecific, + product: dict, ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Teslemetry Vehicle", update_interval=VEHICLE_INTERVAL, ) @@ -69,16 +80,32 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TeslemetryConfigEntry + updated_once: bool + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: EnergySpecific, + data: dict, + ) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Teslemetry Energy Site Live", update_interval=ENERGY_LIVE_INTERVAL, ) self.api = api + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + self.data = data + async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" @@ -100,11 +127,20 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: + config_entry: TeslemetryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: EnergySpecific, + product: dict, + ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Teslemetry Energy Site Info", update_interval=ENERGY_INFO_INTERVAL, ) @@ -127,11 +163,19 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TeslemetryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: EnergySpecific, + ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"Teslemetry Energy History {api.energy_site_id}", update_interval=ENERGY_HISTORY_INTERVAL, ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index d14ef385b9c..de91f43f084 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -2,9 +2,12 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand +from teslemetry_stream import Signal +from teslemetry_stream.const import WindowState from homeassistant.components.cover import ( CoverDeviceClass, @@ -12,10 +15,15 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -28,35 +36,100 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry cover platform from a config entry.""" async_add_entities( - klass(vehicle, entry.runtime_data.scopes) - for (klass) in ( - TeslemetryWindowEntity, - TeslemetryChargePortEntity, - TeslemetryFrontTrunkEntity, - TeslemetryRearTrunkEntity, - TeslemetrySunroofEntity, + chain( + ( + TeslemetryPollingWindowEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingChargePortEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingChargePortEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingFrontTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingRearTrunkEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingRearTrunkEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the windows.""" +class CoverRestoreEntity(RestoreEntity, CoverEntity): + """Restore class for cover entities.""" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + if state.state == "open": + self._attr_is_closed = False + elif state.state == "closed": + self._attr_is_closed = True + + +class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity): + """Base class for window cover entities.""" _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.VENT) + ) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.CLOSE) + ) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslemetryPollingWindowEntity( + TeslemetryVehicleEntity, TeslemetryWindowEntity, CoverEntity +): + """Polling cover entity for windows.""" def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" super().__init__(data, "windows") self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) @@ -67,38 +140,108 @@ class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): rd = self.get("vehicle_state_rd_window") rp = self.get("vehicle_state_rp_window") - # Any open set to open if OPEN in (fd, fp, rd, rp): self._attr_is_closed = False - # All closed set to closed - elif CLOSED == fd == fp == rd == rp: + elif None in (fd, fp, rd, rp): + self._attr_is_closed = None + else: self._attr_is_closed = True - async def async_open_cover(self, **kwargs: Any) -> None: - """Vent windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.window_control(command=WindowCommand.VENT) + +class TeslemetryStreamingWindowEntity( + TeslemetryVehicleStreamEntity, TeslemetryWindowEntity, CoverRestoreEntity +): + """Streaming cover entity for windows.""" + + fd: bool | None = None + fp: bool | None = None + rd: bool | None = None + rp: bool | None = None + + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__( + data, + "windows", ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + for signal in ( + Signal.FD_WINDOW, + Signal.FP_WINDOW, + Signal.RD_WINDOW, + Signal.RP_WINDOW, + ): + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(signal), + f"Adding field {signal} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data) -> None: + """Update the entity attributes.""" + + if value := data.get(Signal.FD_WINDOW): + self.fd = WindowState.get(value) == "closed" + if value := data.get(Signal.FP_WINDOW): + self.fp = WindowState.get(value) == "closed" + if value := data.get(Signal.RD_WINDOW): + self.rd = WindowState.get(value) == "closed" + if value := data.get(Signal.RP_WINDOW): + self.rp = WindowState.get(value) == "closed" + + if False in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = False + elif None in (self.fd, self.fp, self.rd, self.rp): + self._attr_is_closed = None + else: + self._attr_is_closed = True + + self.async_write_ha_state() + + +class TeslemetryChargePortEntity( + TeslemetryRootEntity, + CoverEntity, +): + """Base class for for charge port cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open charge port.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) + + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_closed = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: - """Close windows.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.window_control(command=WindowCommand.CLOSE) - ) + """Close charge port.""" + self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) + + await handle_vehicle_command(self.api.charge_port_door_close()) self._attr_is_closed = True self.async_write_ha_state() -class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the charge port.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingChargePortEntity( + TeslemetryVehicleEntity, TeslemetryChargePortEntity +): + """Polling cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" @@ -117,75 +260,113 @@ class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): """Update the entity attributes.""" self._attr_is_closed = not self._value - async def async_open_cover(self, **kwargs: Any) -> None: - """Open charge port.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.charge_port_door_open()) - self._attr_is_closed = False - self.async_write_ha_state() - async def async_close_cover(self, **kwargs: Any) -> None: - """Close charge port.""" - self.raise_for_scope(Scope.VEHICLE_CHARGING_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.charge_port_door_close()) - self._attr_is_closed = True - self.async_write_ha_state() - - -class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the front trunk.""" - - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryStreamingChargePortEntity( + TeslemetryVehicleStreamEntity, TeslemetryChargePortEntity, CoverRestoreEntity +): + """Streaming cover entity for the charge port.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_ft") - - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = CoverEntityFeature.OPEN + """Initialize the sensor.""" + super().__init__( + vehicle, + "charge_state_charge_port_door_open", + ) + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None - def _async_update_attrs(self) -> None: - """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortDoorOpen( + self._async_value_from_stream + ) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Base class for the front trunk cover entities.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN async def async_open_cover(self, **kwargs: Any) -> None: """Open front trunk.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT)) self._attr_is_closed = False self.async_write_ha_state() + # In the future this could be extended to add aftermarket close support through a option flow -class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the rear trunk.""" - _attr_device_class = CoverDeviceClass.DOOR +class TeslemetryPollingFrontTrunkEntity( + TeslemetryVehicleEntity, TeslemetryFrontTrunkEntity +): + """Polling cover entity for the front trunk.""" def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: """Initialize the cover.""" - super().__init__(vehicle, "vehicle_state_rt") - self.scoped = Scope.VEHICLE_CMDS in scopes - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_ft") def _async_update_attrs(self) -> None: """Update the entity attributes.""" self._attr_is_closed = self._value == CLOSED + +class TeslemetryStreamingFrontTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryFrontTrunkEntity, CoverRestoreEntity +): + """Streaming cover entity for the front trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, "vehicle_state_ft") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkFront(self._async_value_from_stream) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() + + +class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity): + """Cover entity for the rear trunk.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" if self.is_closed is not False: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = False self.async_write_ha_state() @@ -194,12 +375,55 @@ class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): """Close rear trunk.""" if self.is_closed is not True: self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = True self.async_write_ha_state() +class TeslemetryPollingRearTrunkEntity( + TeslemetryVehicleEntity, TeslemetryRearTrunkEntity +): + """Base class for the rear trunk cover entities.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + super().__init__(vehicle, "vehicle_state_rt") + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = self._value == CLOSED + + +class TeslemetryStreamingRearTrunkEntity( + TeslemetryVehicleStreamEntity, TeslemetryRearTrunkEntity, CoverRestoreEntity +): + """Polling cover entity for the rear trunk.""" + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_rt") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.vehicle.stream_vehicle.listen_TrunkRear(self._async_value_from_stream) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: + """Update the entity attributes.""" + + self._attr_is_closed = None if value is None else not value + + class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): """Cover entity for the sunroof.""" @@ -210,7 +434,7 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): _attr_entity_registry_enabled_default = False def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: - """Initialize the sensor.""" + """Initialize the cover.""" super().__init__(vehicle, "vehicle_state_sun_roof_state") self.scoped = Scope.VEHICLE_CMDS in scopes @@ -232,7 +456,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) self._attr_is_closed = False self.async_write_ha_state() @@ -240,7 +463,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) self._attr_is_closed = True self.async_write_ha_state() @@ -248,7 +470,6 @@ class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Close sunroof.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) self._attr_is_closed = False self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 2b0ffd88cc6..6a758e68497 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -2,86 +2,175 @@ from __future__ import annotations -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from collections.abc import Callable +from dataclasses import dataclass + +from teslemetry_stream import TeslemetryStreamVehicle +from teslemetry_stream.const import TeslaLocation + +from homeassistant.components.device_tracker.config_entry import ( + TrackerEntity, + TrackerEntityDescription, +) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import TeslemetryVehicleEntity, TeslemetryVehicleStreamEntity from .models import TeslemetryVehicleData PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription): + """Describe a Teslemetry device tracker entity.""" + + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]], + Callable[[], None], + ] + name_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None] + ] + | None + ) = None + streaming_firmware: str + polling_prefix: str | None = None + + +DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( + TeslemetryDeviceTrackerEntityDescription( + key="location", + polling_prefix="drive_state", + value_listener=lambda x, y: x.listen_Location(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="route", + polling_prefix="drive_state_active_route", + value_listener=lambda x, y: x.listen_DestinationLocation(y), + name_listener=lambda x, y: x.listen_DestinationName(y), + streaming_firmware="2024.26", + ), + TeslemetryDeviceTrackerEntityDescription( + key="origin", + value_listener=lambda x, y: x.listen_OriginLocation(y), + streaming_firmware="2024.26", + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry device tracker platform from a config entry.""" - async_add_entities( - klass(vehicle) - for klass in ( - TeslemetryDeviceTrackerLocationEntity, - TeslemetryDeviceTrackerRouteEntity, - ) - for vehicle in entry.runtime_data.vehicles - ) + entities: list[ + TeslemetryPollingDeviceTrackerEntity | TeslemetryStreamingDeviceTrackerEntity + ] = [] + for vehicle in entry.runtime_data.vehicles: + for description in DESCRIPTIONS: + if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if description.polling_prefix: + entities.append( + TeslemetryPollingDeviceTrackerEntity(vehicle, description) + ) + else: + entities.append( + TeslemetryStreamingDeviceTrackerEntity(vehicle, description) + ) + + async_add_entities(entities) -class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): - """Base class for Teslemetry tracker entities.""" +class TeslemetryPollingDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): + """Base class for Teslemetry Tracker Entities.""" - lat_key: str - lon_key: str + entity_description: TeslemetryDeviceTrackerEntityDescription def __init__( self, vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, ) -> None: """Initialize the device tracker.""" - super().__init__(vehicle, self.key) + self.entity_description = description + super().__init__(vehicle, description.key) def _async_update_attrs(self) -> None: - """Update the attributes of the device tracker.""" - + """Update the attributes of the entity.""" + self._attr_latitude = self.get( + f"{self.entity_description.polling_prefix}_latitude" + ) + self._attr_longitude = self.get( + f"{self.entity_description.polling_prefix}_longitude" + ) + self._attr_location_name = self.get( + f"{self.entity_description.polling_prefix}_destination" + ) + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME self._attr_available = ( - self.get(self.lat_key, False) is not None - and self.get(self.lon_key, False) is not None + self._attr_latitude is not None and self._attr_longitude is not None ) - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self.get(self.lat_key) - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self.get(self.lon_key) +class TeslemetryStreamingDeviceTrackerEntity( + TeslemetryVehicleStreamEntity, TrackerEntity, RestoreEntity +): + """Base class for Teslemetry Tracker Entities.""" + entity_description: TeslemetryDeviceTrackerEntityDescription -class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): - """Vehicle location device tracker class.""" + def __init__( + self, + vehicle: TeslemetryVehicleData, + description: TeslemetryDeviceTrackerEntityDescription, + ) -> None: + """Initialize the device tracker.""" + self.entity_description = description + super().__init__(vehicle, description.key) - key = "location" - lat_key = "drive_state_latitude" - lon_key = "drive_state_longitude" + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_state = state.state + self._attr_latitude = state.attributes.get("latitude") + self._attr_longitude = state.attributes.get("longitude") + self._attr_location_name = state.attributes.get("location_name") + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._location_callback + ) + ) + if self.entity_description.name_listener: + self.async_on_remove( + self.entity_description.name_listener( + self.vehicle.stream_vehicle, self._name_callback + ) + ) + def _location_callback(self, location: TeslaLocation | None) -> None: + """Update the value of the entity.""" + if location is None: + self._attr_available = False + else: + self._attr_available = True + self._attr_latitude = location.latitude + self._attr_longitude = location.longitude + self.async_write_ha_state() -class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): - """Vehicle navigation device tracker class.""" - - key = "route" - lat_key = "drive_state_active_route_latitude" - lon_key = "drive_state_active_route_longitude" - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - location = self.get("drive_state_active_route_destination") - if location == "Home": - return STATE_HOME - return location + def _name_callback(self, name: str | None) -> None: + """Update the value of the entity.""" + self._attr_location_name = name + if self._attr_location_name == "Home": + self._attr_location_name = STATE_HOME + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index 7e9c8a9a5b0..755935951fc 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -35,14 +35,19 @@ async def async_get_config_entry_diagnostics( vehicles = [ { "data": async_redact_data(x.coordinator.data, VEHICLE_REDACT), - # Stream diag will go here when implemented + "stream": { + "config": x.stream_vehicle.config, + }, } for x in entry.runtime_data.vehicles ] energysites = [ { - "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT) + if x.live_coordinator + else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), + "history": x.history_coordinator.data if x.history_coordinator else None, } for x in entry.runtime_data.energysites ] diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index d14f3a42734..82d3db123c3 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -3,11 +3,14 @@ from abc import abstractmethod from typing import Any +from propcache.api import cached_property from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -21,18 +24,33 @@ from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData +class TeslemetryRootEntity(Entity): + """Parent class for all Teslemetry entities.""" + + _attr_has_entity_name = True + scoped: bool + api: VehicleSpecific | EnergySpecific + + def raise_for_scope(self, scope: Scope): + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_scope", + translation_placeholders={"scope": scope}, + ) + + class TeslemetryEntity( + TeslemetryRootEntity, CoordinatorEntity[ TeslemetryVehicleDataCoordinator | TeslemetryEnergyHistoryCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator - ] + ], ): - """Parent class for all Teslemetry entities.""" - - _attr_has_entity_name = True - scoped: bool + """Parent class for all Teslemetry Coordinator entities.""" def __init__( self, @@ -73,11 +91,6 @@ class TeslemetryEntity( """Return if the value is a literal None.""" return self.get(self.key, False) is None - @property - def has(self) -> bool: - """Return True if a specific value is in coordinator data.""" - return self.key in self.coordinator.data - def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._async_update_attrs() @@ -87,15 +100,6 @@ class TeslemetryEntity( def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - def raise_for_scope(self, scope: Scope): - """Raise an error if a scope is not available.""" - if not self.scoped: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_scope", - translation_placeholders={"scope": scope}, - ) - class TeslemetryVehicleEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle entities.""" @@ -139,6 +143,8 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry Energy Site Live entity.""" + assert data.live_coordinator + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device @@ -198,6 +204,8 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry entity.""" + assert data.live_coordinator + self.api = data.api self.din = din self._attr_unique_id = f"{data.id}-{din}-{key}" @@ -236,3 +244,53 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): return self.key in self.coordinator.data.get("wall_connectors", {}).get( self.din, {} ) + + +class TeslemetryVehicleStreamEntity(TeslemetryRootEntity): + """Parent class for Teslemetry Vehicle Stream entities.""" + + def __init__( + self, data: TeslemetryVehicleData, key: str, streaming_key: Signal | None = None + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + self.streaming_key = streaming_key + self.vehicle = data + + self.api = data.api + self.stream = data.stream + self.vin = data.vin + self.add_field = data.stream.get_vehicle(self.vin).add_field + + self._attr_translation_key = key + self._attr_unique_id = f"{data.vin}-{key}" + self._attr_device_info = data.device + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.streaming_key: + self.async_on_remove( + self.stream.async_add_listener( + self._handle_stream_update, + {"vin": self.vin, "data": {self.streaming_key: None}}, + ) + ) + self.vehicle.config_entry.async_create_background_task( + self.hass, + self.add_field(self.streaming_key), + f"Adding field {self.streaming_key.value} to {self.vehicle.vin}", + ) + + def _handle_stream_update(self, data: dict[str, Any]) -> None: + """Handle updated data from the stream.""" + self._async_value_from_stream(data["data"][self.streaming_key]) + self.async_write_ha_state() + + def _async_value_from_stream(self, value: Any) -> None: + """Update the entity with the latest value from the stream.""" + raise NotImplementedError + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 6559acf89dc..9996a508177 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -291,7 +291,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 4600391145b..68505a12a13 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope @@ -9,11 +10,16 @@ from tesla_fleet_api.const import Scope from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -25,36 +31,43 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry lock platform from a config entry.""" async_add_entities( - klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) - for klass in ( - TeslemetryVehicleLockEntity, - TeslemetryCableLockEntity, + chain( + ( + TeslemetryPollingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): - """Lock entity for Teslemetry.""" - - def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: - """Initialize the lock.""" - super().__init__(data, "vehicle_state_locked") - self.scoped = scoped - - def _async_update_attrs(self) -> None: - """Update entity attributes.""" - self._attr_is_locked = self._value +class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): + """Base vehicle lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_lock()) self._attr_is_locked = True self.async_write_ha_state() @@ -62,27 +75,65 @@ class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_unlock()) self._attr_is_locked = False self.async_write_ha_state() -class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): - """Cable Lock entity for Teslemetry.""" +class TeslemetryPollingVehicleLockEntity( + TeslemetryVehicleEntity, TeslemetryVehicleLockEntity +): + """Polling vehicle lock entity for Teslemetry.""" - def __init__( - self, - data: TeslemetryVehicleData, - scoped: bool, - ) -> None: - """Initialize the lock.""" - super().__init__(data, "charge_state_charge_port_latch") + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) self.scoped = scoped def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._attr_is_locked = self._value == ENGAGED + self._attr_is_locked = self._value + + +class TeslemetryStreamingVehicleLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleLockEntity, RestoreEntity +): + """Streaming vehicle lock entity for Teslemetry.""" + + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove(self.vehicle.stream_vehicle.listen_Locked(self._callback)) + + def _callback(self, value: bool | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = value + self.async_write_ha_state() + + +class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): + """Base cable Lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" @@ -95,7 +146,70 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock charge cable lock.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_locked = False self.async_write_ha_state() + + +class TeslemetryPollingCableLockEntity( + TeslemetryVehicleEntity, TeslemetryCableLockEntity +): + """Polling cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + if self._value is None: + self._attr_is_locked = None + self._attr_is_locked = self._value == ENGAGED + + +class TeslemetryStreamingCableLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryCableLockEntity, RestoreEntity +): + """Streaming cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortLatch(self._callback) + ) + + def _callback(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = None if value is None else value == ENGAGED + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index a2782d25393..4e9228acd2f 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.2", "teslemetry-stream==0.4.2"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index e0e144ffe3a..1bfc9bf66dc 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Media platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index d3969b30a7c..5b78386c68a 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -8,8 +8,9 @@ from dataclasses import dataclass from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope -from teslemetry_stream import TeslemetryStream +from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( @@ -34,12 +35,15 @@ class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" api: VehicleSpecific + config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream + stream_vehicle: TeslemetryStreamVehicle vin: str - wakelock = asyncio.Lock() + firmware: str device: DeviceInfo remove_listener: Callable + wakelock = asyncio.Lock() @dataclass @@ -47,7 +51,7 @@ class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" api: EnergySpecific - live_coordinator: TeslemetryEnergySiteLiveCoordinator + live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None id: int diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 9ba9c28b199..10c15a68b09 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -9,20 +9,33 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, + RestoreNumber, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfElectricCurrent, ) -from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,12 +46,22 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] - native_min_value: float - native_max_value: float + func: Callable[[VehicleSpecific, int], Awaitable[Any]] min_key: str | None = None max_key: str + native_min_value: float + native_max_value: float scopes: list[Scope] + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + max_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], Callable[[], None] + ] + | None + ) = None VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( @@ -52,7 +75,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( mode=NumberMode.AUTO, max_key="charge_state_charge_current_request_max", func=lambda api, value: api.set_charging_amps(value), - scopes=[Scope.VEHICLE_CHARGING_CMDS], + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeCurrentRequest(y), + max_listener=lambda x, y: x.listen_ChargeCurrentRequestMax(y), ), TeslemetryNumberVehicleEntityDescription( key="charge_state_charge_limit_soc", @@ -62,10 +87,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=NumberDeviceClass.BATTERY, mode=NumberMode.AUTO, - min_key="charge_state_charge_limit_soc_min", max_key="charge_state_charge_limit_soc_max", func=lambda api, value: api.set_charge_limit(value), scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeLimitSoc(y), ), ) @@ -76,16 +101,29 @@ class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): func: Callable[[EnergySpecific, float], Awaitable[Any]] requires: str | None = None + scopes: list[Scope] ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = ( TeslemetryNumberBatteryEntityDescription( key="backup_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.backup(int(value)), requires="components_battery", ), TeslemetryNumberBatteryEntityDescription( key="off_grid_vehicle_charging_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), requires="components_off_grid_vehicle_charging_reserve_supported", ), @@ -95,14 +133,20 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry number platform from a config entry.""" async_add_entities( chain( - ( # Add vehicle entities - TeslemetryVehicleNumberEntity( + ( + TeslemetryPollingNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingNumberEntity( vehicle, description, entry.runtime_data.scopes, @@ -110,7 +154,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( # Add energy site entities + ( TeslemetryEnergyInfoNumberSensorEntity( energysite, description, @@ -125,11 +169,25 @@ async def async_setup_entry( ) -class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): +class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" entity_description: TeslemetryNumberVehicleEntityDescription + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslemetryPollingNumberEntity( + TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity +): + """Vehicle polling number entity.""" + def __init__( self, data: TeslemetryVehicleData, @@ -148,26 +206,67 @@ class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): """Update the attributes of the entity.""" self._attr_native_value = self._value - if (min_key := self.entity_description.min_key) is not None: - self._attr_native_min_value = self.get_number( - min_key, - self.entity_description.native_min_value, - ) - else: - self._attr_native_min_value = self.entity_description.native_min_value - self._attr_native_max_value = self.get_number( self.entity_description.max_key, self.entity_description.native_max_value, ) - async def async_set_native_value(self, value: float) -> None: - """Set new value.""" - value = int(value) - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.func(self.api, value)) - self._attr_native_value = value + +class TeslemetryStreamingNumberEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleNumberEntity, RestoreNumber +): + """Number entity for current charge.""" + + entity_description: TeslemetryNumberVehicleEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + self._attr_native_max_value = self.entity_description.native_max_value + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_native_value = last_number_data.native_value + if last_number_data.native_max_value: + self._attr_native_max_value = last_number_data.native_max_value + + # Add listeners + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + if self.entity_description.max_listener: + self.async_on_remove( + self.entity_description.max_listener( + self.vehicle.stream_vehicle, self._max_callback + ) + ) + + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_value = None if value is None else value + self.async_write_ha_state() + + def _max_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_max_value = ( + self.entity_description.native_max_value if value is None else value + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index baf1d80ac6c..0d268e302de 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -2,18 +2,27 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain +from typing import Any +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -24,53 +33,136 @@ HIGH = "high" PARALLEL_UPDATES = 0 +LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} + @dataclass(frozen=True, kw_only=True) -class SeatHeaterDescription(SelectEntityDescription): +class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - position: Seat - available_fn: Callable[[TeslemetrySeatHeaterSelectEntity], bool] = lambda _: True + select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + supported_fn: Callable[[dict], bool] = lambda _: True + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + | None + ) = None + options: list[str] -SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( - SeatHeaterDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_left", - position=Seat.FRONT_LEFT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_LEFT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_right", - position=Seat.FRONT_RIGHT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_RIGHT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_left", - position=Seat.REAR_LEFT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_LEFT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearLeft(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_center", - position=Seat.REAR_CENTER, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_CENTER, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearCenter(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_right", - position=Seat.REAR_RIGHT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_RIGHT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearRight(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_left", - position=Seat.THIRD_LEFT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_LEFT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_right", - position=Seat.THIRD_RIGHT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_RIGHT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_steering_wheel_heat_level", + select_fn=lambda api, level: api.remote_steering_wheel_heat_level_request( + level + ), + streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatLevel(y), + options=[ + OFF, + LOW, + HIGH, + ], ), ) @@ -78,24 +170,25 @@ SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry select platform from a config entry.""" async_add_entities( chain( ( - TeslemetrySeatHeaterSelectEntity( + TeslemetryPollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - for description in SEAT_HEATER_DESCRIPTIONS + if vehicle.api.pre2021 + or vehicle.firmware < "2024.26" + or description.streaming_listener is None + else TeslemetryStreamingSelectEntity( + vehicle, description, entry.runtime_data.scopes + ) + for description in VEHICLE_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles - if description.key in vehicle.coordinator.data - ), - ( - TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("climate_state_steering_wheel_heater") + if description.supported_fn(vehicle.coordinator.data) ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) @@ -112,22 +205,31 @@ async def async_setup_entry( ) -class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle seat heater.""" +class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): + """Parent vehicle select entity class.""" - entity_description: SeatHeaterDescription + entity_description: TeslemetrySelectEntityDescription + _climate: bool = False - _attr_options = [ - OFF, - LOW, - MEDIUM, - HIGH, - ] + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + level = LEVEL[option] + # AC must be on to turn on heaters + if level and not self._climate: + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command(self.entity_description.select_fn(self.api, level)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryPollingSelectEntity(TeslemetryVehicleEntity, TeslemetrySelectEntity): + """Base polling vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, - description: SeatHeaterDescription, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: """Initialize the vehicle seat select entity.""" @@ -137,72 +239,63 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" - self._attr_available = self.entity_description.available_fn(self) - value = self._value - if not isinstance(value, int): + self._climate = bool(self.get("climate_state_is_climate_on")) + if not isinstance(self._value, int): self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on seat heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_seat_heater_request(self.entity_description.position, level) - ) - self._attr_current_option = option - self.async_write_ha_state() + self._attr_current_option = self.entity_description.options[self._value] -class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle steering wheel heater.""" - - _attr_options = [ - OFF, - LOW, - HIGH, - ] +class TeslemetryStreamingSelectEntity( + TeslemetryVehicleStreamEntity, TeslemetrySelectEntity, RestoreEntity +): + """Base streaming vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: - """Initialize the vehicle steering wheel select entity.""" + """Initialize the vehicle seat select entity.""" + self.entity_description = description self.scoped = Scope.VEHICLE_CMDS in scopes - super().__init__( - data, - "climate_state_steering_wheel_heat_level", + self._attr_current_option = None + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state in self.entity_description.options: + self._attr_current_option = state.state + + # Listen for streaming data + assert self.entity_description.streaming_listener is not None + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) ) - def _async_update_attrs(self) -> None: - """Handle updated data from the coordinator.""" + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled(self._climate_callback) + ) - value = self._value - if not isinstance(value, int): + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + if value is None: self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on steering wheel heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_steering_wheel_heat_level_request(level) - ) - self._attr_current_option = option + self._attr_current_option = self.entity_description.options[value] self.async_write_ha_state() + def _climate_callback(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._climate = bool(value) + class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): """Select entity for operation mode select entities.""" diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 95876cc2cf9..56c8830d736 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -4,11 +4,14 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta -from itertools import chain -from typing import cast +from datetime import datetime, timedelta + +from propcache.api import cached_property +from teslemetry_stream import Signal +from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -28,7 +31,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance @@ -40,6 +43,7 @@ from .entity import ( TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -59,125 +63,165 @@ SHIFT_STATES = {"P": "p", "D": "d", "R": "r", "N": "n"} @dataclass(frozen=True, kw_only=True) -class TeslemetrySensorEntityDescription(SensorEntityDescription): +class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" - value_fn: Callable[[StateType], StateType] = lambda x: x + polling: bool = False + polling_value_fn: Callable[[StateType], StateType] = lambda x: x + polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + streaming_key: Signal | None = None + streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x + streaming_firmware: str = "2024.26" -VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( - TeslemetrySensorEntityDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charging_state", + polling=True, + streaming_key=Signal.DETAILED_CHARGE_STATE, + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), + streaming_value_fn=lambda value: CHARGE_STATES.get( + str(value).replace("DetailedChargeState", "") + ), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda value: CHARGE_STATES.get(cast(str, value)), ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", + polling=True, + streaming_key=Signal.BATTERY_LEVEL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", + polling=True, + streaming_key=Signal.AC_CHARGING_ENERGY_IN, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", + polling=True, + streaming_key=Signal.AC_CHARGING_POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", + polling=True, + streaming_key=Signal.CHARGE_AMPS, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_rate", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", + polling=True, + streaming_key=Signal.CHARGING_CABLE_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", + polling=True, + streaming_key=Signal.FAST_CHARGER_TYPE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_range", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", + polling=True, + streaming_key=Signal.EST_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", + polling=True, + streaming_key=Signal.IDEAL_BATTERY_RANGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_speed", + polling=True, + polling_value_fn=lambda value: value or 0, + streaming_key=Signal.VEHICLE_SPEED, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_power", + polling=True, + polling_value_fn=lambda value: value or 0, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda value: value or 0, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", + polling=True, + polling_available_fn=lambda x: True, + polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), + streaming_key=Signal.GEAR, + streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", + polling=True, + streaming_key=Signal.ODOMETER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -185,8 +229,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -195,8 +241,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_FR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -205,8 +253,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -215,8 +265,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", + polling=True, + streaming_key=Signal.TPMS_PRESSURE_RR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -225,22 +277,27 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", + polling=True, + streaming_key=Signal.INSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", + polling=True, + streaming_key=Signal.OUTSIDE_TEMP, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, suggested_display_precision=1, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_driver_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -248,8 +305,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="climate_state_passenger_temp_setting", + polling=True, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -257,23 +315,29 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", + polling=True, + streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", + polling=True, + streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - TeslemetrySensorEntityDescription( + TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", + polling=True, + streaming_key=Signal.MILES_TO_ARRIVAL, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -286,24 +350,36 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int + streaming_key: Signal + streaming_firmware: str = "2024.26" VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", + streaming_key=Signal.TIME_TO_FULL_CHARGE, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", + streaming_key=Signal.MINUTES_TO_ARRIVAL, device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class TeslemetryEnergySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + + +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryEnergySensorEntityDescription, ...] = ( + TeslemetryEnergySensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -311,7 +387,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -320,7 +396,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -330,14 +406,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -345,7 +422,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -353,7 +430,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -361,7 +438,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -369,7 +446,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -378,7 +455,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="island_status", device_class=SensorDeviceClass.ENUM, options=[ @@ -391,6 +468,14 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) + +@dataclass(frozen=True, kw_only=True) +class TeslemetrySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( TeslemetrySensorEntityDescription( key="wall_connector_state", @@ -445,58 +530,113 @@ ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" - async_add_entities( - chain( - ( # Add vehicles - TeslemetryVehicleSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_DESCRIPTIONS - ), - ( # Add vehicles time sensors - TeslemetryVehicleTimeSensorEntity(vehicle, description) - for vehicle in entry.runtime_data.vehicles - for description in VEHICLE_TIME_DESCRIPTIONS - ), - ( # Add energy site live - TeslemetryEnergyLiveSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data - ), - ( # Add wall connectors - TeslemetryWallConnectorSensorEntity(energysite, din, description) - for energysite in entry.runtime_data.energysites - for din in energysite.live_coordinator.data.get("wall_connectors", {}) - for description in WALL_CONNECTOR_DESCRIPTIONS - ), - ( # Add energy site info - TeslemetryEnergyInfoSensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_INFO_DESCRIPTIONS - if description.key in energysite.info_coordinator.data - ), - ( # Add energy history sensor - TeslemetryEnergyHistorySensorEntity(energysite, description) - for energysite in entry.runtime_data.energysites - for description in ENERGY_HISTORY_DESCRIPTIONS - if energysite.history_coordinator - ), - ) + + entities: list[SensorEntity] = [] + for vehicle in entry.runtime_data.vehicles: + for description in VEHICLE_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and description.streaming_key + and vehicle.firmware >= description.streaming_firmware + ): + entities.append(TeslemetryStreamSensorEntity(vehicle, description)) + elif description.polling: + entities.append(TeslemetryVehicleSensorEntity(vehicle, description)) + + for time_description in VEHICLE_TIME_DESCRIPTIONS: + if ( + not vehicle.api.pre2021 + and vehicle.firmware >= time_description.streaming_firmware + ): + entities.append( + TeslemetryStreamTimeSensorEntity(vehicle, time_description) + ) + else: + entities.append( + TeslemetryVehicleTimeSensorEntity(vehicle, time_description) + ) + + entities.extend( + TeslemetryEnergyLiveSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ) + entities.extend( + TeslemetryWallConnectorSensorEntity(energysite, din, description) + for energysite in entry.runtime_data.energysites + if energysite.live_coordinator + for din in energysite.live_coordinator.data.get("wall_connectors", {}) + for description in WALL_CONNECTOR_DESCRIPTIONS + ) + + entities.extend( + TeslemetryEnergyInfoSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ) + + entities.extend( + TeslemetryEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.history_coordinator is not None + ) + + async_add_entities(entities) + + +class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryVehicleSensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryVehicleSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self.entity_description.streaming_value_fn(value) + class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" - entity_description: TeslemetrySensorEntityDescription + entity_description: TeslemetryVehicleSensorEntityDescription def __init__( self, data: TeslemetryVehicleData, - description: TeslemetrySensorEntityDescription, + description: TeslemetryVehicleSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -504,12 +644,48 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.has: - self._attr_native_value = self.entity_description.value_fn(self._value) + if self.entity_description.polling_available_fn(self._value): + self._attr_available = True + self._attr_native_value = self.entity_description.polling_value_fn( + self._value + ) else: + self._attr_available = False self._attr_native_value = None +class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEntity): + """Base class for Teslemetry vehicle streaming sensors.""" + + entity_description: TeslemetryTimeEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryTimeEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + self._get_timestamp = ignore_variance( + func=lambda value: dt_util.now() + timedelta(minutes=value), + ignored_variance=timedelta(minutes=description.variance), + ) + assert description.streaming_key + super().__init__(data, description.key, description.streaming_key) + + @cached_property + def available(self) -> bool: + """Return True if entity is available.""" + return self.stream.connected + + def _async_value_from_stream(self, value) -> None: + """Update the value of the entity.""" + if value is None: + self._attr_native_value = None + else: + self._attr_native_value = self._get_timestamp(value) + + class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle time sensors.""" @@ -539,12 +715,12 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity): """Base class for Teslemetry energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslemetryEnergySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: SensorEntityDescription, + description: TeslemetryEnergySensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -553,7 +729,7 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_available = not self.is_none - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 97cfffa1699..8215adb5711 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -98,7 +98,7 @@ def async_get_energy_site_for_entry( return energy_data -def async_register_services(hass: HomeAssistant) -> None: # noqa: C901 +def async_register_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" async def navigate_gps_request(call: ServiceCall) -> None: diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4f4bc2ae60c..9dc17fd2ef7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -51,7 +51,7 @@ "name": "Trip charging" }, "climate_state_cabin_overheat_protection_actively_cooling": { - "name": "Cabin overheat protection actively cooling" + "name": "Cabin overheat protection active" }, "climate_state_is_preconditioning": { "name": "Preconditioning" @@ -68,6 +68,27 @@ "storm_mode_active": { "name": "Storm watch active" }, + "automatic_blind_spot_camera": { + "name": "Automatic blind spot camera" + }, + "automatic_emergency_braking_off": { + "name": "Automatic emergency braking off" + }, + "blind_spot_collision_warning_chime": { + "name": "Blind spot collision warning chime" + }, + "bms_full_charge_complete": { + "name": "BMS full charge" + }, + "brake_pedal": { + "name": "Brake pedal" + }, + "charge_port_cold_weather_mode": { + "name": "Charge port cold weather mode" + }, + "service_mode": { + "name": "Service mode" + }, "vehicle_state_dashcam_state": { "name": "Dashcam" }, @@ -109,6 +130,66 @@ }, "vehicle_state_tpms_soft_warning_rr": { "name": "Tire pressure warning rear right" + }, + "pin_to_drive_enabled": { + "name": "Pin to drive enabled" + }, + "drive_rail": { + "name": "Drive rail" + }, + "driver_seat_belt": { + "name": "Driver seat belt" + }, + "driver_seat_occupied": { + "name": "Driver seat occupied" + }, + "passenger_seat_belt": { + "name": "Passenger seat belt" + }, + "fast_charger_present": { + "name": "Fast charger present" + }, + "gps_state": { + "name": "GPS state" + }, + "guest_mode_enabled": { + "name": "Guest mode enabled" + }, + "dc_dc_enable": { + "name": "DC to DC converter" + }, + "emergency_lane_departure_avoidance": { + "name": "Emergency lane departure avoidance" + }, + "supercharger_session_trip_planner": { + "name": "Supercharger session trip planner" + }, + "wiper_heat_enabled": { + "name": "Wiper heat" + }, + "rear_display_hvac_enabled": { + "name": "Rear display HVAC" + }, + "offroad_lightbar_present": { + "name": "Offroad lightbar" + }, + "homelink_nearby": { + "name": "Homelink nearby" + }, + "europe_vehicle": { + "name": "European vehicle" + }, + "right_hand_drive": { + "name": "Right hand drive" + }, + "located_at_home": { + "name": "Located at home" + }, + "located_at_work": { + "name": "Located at work" + }, + "located_at_favorite": { + "name": "Located at favorite" } }, "button": { @@ -155,6 +236,9 @@ }, "route": { "name": "Route" + }, + "origin": { + "name": "Origin" } }, "lock": { @@ -331,7 +415,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", @@ -524,7 +608,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { @@ -610,7 +694,7 @@ }, "services": { "navigation_gps_request": { - "description": "Set vehicle navigation to the provided latitude/longitude coordinates.", + "description": "Sets vehicle navigation to the provided latitude/longitude coordinates.", "fields": { "device_id": { "description": "Vehicle to share to.", @@ -628,7 +712,7 @@ "name": "Navigate to coordinates" }, "set_scheduled_charging": { - "description": "Sets a time at which charging should be completed.", + "description": "Sets a time at which charging should be started.", "fields": { "device_id": { "description": "Vehicle to schedule.", @@ -646,7 +730,7 @@ "name": "Set scheduled charging" }, "set_scheduled_departure": { - "description": "Sets a time at which departure should be completed.", + "description": "Sets the departure time for a vehicle to schedule charging and preconditioning.", "fields": { "departure_time": { "description": "Time to be preconditioned by.", @@ -684,7 +768,7 @@ "name": "Set scheduled departure" }, "speed_limit": { - "description": "Activate the speed limit of the vehicle.", + "description": "Activates the speed limit of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", @@ -702,7 +786,7 @@ "name": "Set speed limit" }, "time_of_use": { - "description": "Update the time of use settings for the energy site.", + "description": "Updates the time of use settings for an energy site.", "fields": { "device_id": { "description": "Energy Site to configure.", @@ -716,7 +800,7 @@ "name": "Time of use settings" }, "valet_mode": { - "description": "Activate the valet mode of the vehicle.", + "description": "Activates the valet mode of a vehicle.", "fields": { "device_id": { "description": "Vehicle to limit.", diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 6a1cff4c5da..83441e6c4f6 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -15,7 +15,8 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity @@ -32,6 +33,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( @@ -77,20 +80,21 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslemetrySwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + TeslemetrySwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + ), ) async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Switch platform from a config entry.""" @@ -104,12 +108,6 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS if description.key in vehicle.coordinator.data ), - ( - TeslemetryChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslemetryChargeFromGridSwitchEntity( energysite, @@ -145,13 +143,15 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -170,17 +170,6 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt self.async_write_ha_state() -class TeslemetryChargeSwitchEntity(TeslemetryVehicleSwitchEntity): - """Entity class for Teslemetry charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslemetryChargeFromGridSwitchEntity( TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity ): diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 670cd0e0eda..f560f25a8ff 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -8,7 +8,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity @@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry update platform from a config entry.""" diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index a0bc58896e4..f73ecc7a729 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -69,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo vin=vehicle["vin"], data_coordinator=TessieStateUpdateCoordinator( hass, + entry, api_key=api_key, vin=vehicle["vin"], data=vehicle["last_state"], @@ -127,8 +128,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo TessieEnergyData( api=api, id=site_id, - live_coordinator=TessieEnergySiteLiveCoordinator(hass, api), - info_coordinator=TessieEnergySiteInfoCoordinator(hass, api), + live_coordinator=TessieEnergySiteLiveCoordinator( + hass, entry, api + ), + info_coordinator=TessieEnergySiteInfoCoordinator( + hass, entry, api + ), device=DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index fd6565b62b7..515339c3da8 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieState @@ -177,7 +177,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie binary sensor platform from a config entry.""" async_add_entities( diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index bef9c2585f6..a370f504323 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -16,7 +16,7 @@ from tessie_api import ( from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .entity import TessieEntity @@ -50,7 +50,7 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Button platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 1d26926aeaa..a8aa18132ee 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieClimateKeeper @@ -32,7 +32,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Climate platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 4582260bfb2..b06fe6123a5 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -1,9 +1,11 @@ """Tessie Data Coordinator.""" +from __future__ import annotations + from datetime import timedelta from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError from tesla_fleet_api import EnergySpecific @@ -15,6 +17,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TessieConfigEntry + from .const import TessieStatus # This matches the update interval Tessie performs server side @@ -40,9 +45,12 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Tessie API.""" + config_entry: TessieConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: TessieConfigEntry, api_key: str, vin: str, data: dict[str, Any], @@ -51,6 +59,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tessie", update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), ) @@ -90,11 +99,16 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Tessie API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TessieConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tessie Energy Site Live", update_interval=TESSIE_FLEET_API_SYNC_INTERVAL, ) @@ -121,11 +135,16 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Tessie API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TessieConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + ) -> None: """Initialize Tessie Energy Info coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tessie Energy Site Info", update_interval=TESSIE_FLEET_API_SYNC_INTERVAL, ) diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py index e739f8c074d..bfd7b1b816c 100644 --- a/homeassistant/components/tessie/cover.py +++ b/homeassistant/components/tessie/cover.py @@ -22,7 +22,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieCoverStates @@ -35,7 +35,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index df74cd2a7a7..fe81ed67337 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TessieConfigEntry @@ -17,7 +17,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie device tracker platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 76d58a9070c..66cb813b995 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -9,7 +9,7 @@ from tessie_api import lock, open_unlock_charge_port, unlock from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import DOMAIN, TessieChargeCableLockStates @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 8f7c9890664..d4ac56883e8 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.2"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] } diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index 7dfe568926b..139ee07ca5b 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -8,7 +8,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .entity import TessieEntity @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Media platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 74249d392a7..1e857345278 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfSpeed, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TessieConfigEntry @@ -111,7 +111,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TessieNumberBatteryEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index 4dfe7088439..471372a68bd 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -9,7 +9,7 @@ from tessie_api import set_seat_cool, set_seat_heat from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieSeatCoolerOptions, TessieSeatHeaterOptions @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie select platform from a config entry.""" diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 7f09cef2acd..4f62e1b1855 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance @@ -258,6 +258,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), ) + ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -292,6 +293,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), TessieSensorEntityDescription( key="battery_power", @@ -373,7 +375,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4ac645a0270..4f0f5f67ebd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -75,7 +75,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", @@ -212,7 +212,7 @@ "name": "State", "state": { "booting": "Booting", - "charging": "[%key:component::tessie::entity::sensor::charge_state_charging_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", @@ -459,7 +459,7 @@ } }, "switch": { - "charge_state_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_defrost_mode": { @@ -506,7 +506,7 @@ }, "exceptions": { "unknown": { - "message": "An unknown issue occured changing {name}." + "message": "An unknown issue occurred changing {name}." }, "not_supported": { "message": "{name} is not supported." diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index f0088a4444f..41134b38fda 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -26,7 +26,8 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TessieConfigEntry from .entity import TessieEnergyEntity, TessieEntity @@ -40,6 +41,8 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( @@ -63,12 +66,13 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( on_func=lambda: start_steering_wheel_heater, off_func=lambda: stop_steering_wheel_heater, ), -) - -CHARGE_DESCRIPTION: TessieSwitchEntityDescription = TessieSwitchEntityDescription( - key="charge_state_charge_enable_request", - on_func=lambda: start_charging, - off_func=lambda: stop_charging, + TessieSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, + value_func=lambda state: state in {"Starting", "Charging"}, + ), ) PARALLEL_UPDATES = 0 @@ -77,7 +81,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Switch platform from a config entry.""" @@ -89,10 +93,6 @@ async def async_setup_entry( for description in DESCRIPTIONS if description.key in vehicle.data_coordinator.data ), - ( - TessieChargeSwitchEntity(vehicle, CHARGE_DESCRIPTION) - for vehicle in entry.runtime_data.vehicles - ), ( TessieChargeFromGridSwitchEntity(energysite) for energysite in entry.runtime_data.energysites @@ -120,13 +120,15 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): description: TessieSwitchEntityDescription, ) -> None: """Initialize the Switch.""" - super().__init__(vehicle, description.key) self.entity_description = description + super().__init__(vehicle, description.key) + if description.unique_id: + self._attr_unique_id = f"{vehicle.vin}-{description.unique_id}" @property def is_on(self) -> bool: """Return the state of the Switch.""" - return self._value + return self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -139,18 +141,6 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): self.set((self.entity_description.key, False)) -class TessieChargeSwitchEntity(TessieSwitchEntity): - """Entity class for Tessie charge switch.""" - - @property - def is_on(self) -> bool: - """Return the state of the Switch.""" - - if (charge := self.get("charge_state_user_charge_enable_request")) is not None: - return charge - return self._value - - class TessieChargeFromGridSwitchEntity(TessieEnergyEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index f6198fa6c03..e9af673b1f4 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -8,7 +8,7 @@ from tessie_api import schedule_software_update from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieUpdateStatus @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Update platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index d0f5ac7d3b7..27af7e3fe59 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -9,7 +9,7 @@ import logging import re from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index 94269ac12fb..b1eca1e36b6 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index e3aa9060787..9571597abe6 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thermobeacon/config_flow.py b/homeassistant/components/thermobeacon/config_flow.py index 08994a41008..6fa502716ca 100644 --- a/homeassistant/components/thermobeacon/config_flow.py +++ b/homeassistant/components/thermobeacon/config_flow.py @@ -72,7 +72,7 @@ class ThermoBeaconConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index ce6a3f71ef3..e060cbd91bf 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -14,6 +14,12 @@ "manufacturer_data_start": [0], "connectable": false }, + { + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 20, + "manufacturer_data_start": [0], + "connectable": false + }, { "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", "manufacturer_id": 21, @@ -48,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.7.0"] + "requirements": ["thermobeacon-ble==0.8.0"] } diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 53e86f37f11..916ec91359a 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -113,7 +113,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoBeacon BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py index 2cd207818c5..742449cffbe 100644 --- a/homeassistant/components/thermopro/__init__.py +++ b/homeassistant/components/thermopro/__init__.py @@ -2,25 +2,47 @@ from __future__ import annotations +from functools import partial import logging -from thermopro_ble import ThermoProBluetoothDeviceData +from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData -from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_DATA_UPDATED -PLATFORMS: list[Platform] = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +def process_service_info( + hass: HomeAssistant, + entry: ConfigEntry, + data: ThermoProBluetoothDeviceData, + service_info: BluetoothServiceInfoBleak, +) -> SensorUpdate: + """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" + update = data.update(service_info) + async_dispatcher_send( + hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", data, service_info, update + ) + return update + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ThermoPro BLE device from a config entry.""" address = entry.unique_id @@ -32,13 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, address=address, mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, + update_method=partial(process_service_info, hass, entry, data), ) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload( - coordinator.async_start() - ) # only start after all platforms have had a chance to subscribe + # only start after all platforms have had a chance to subscribe + entry.async_on_unload(coordinator.async_start()) return True diff --git a/homeassistant/components/thermopro/button.py b/homeassistant/components/thermopro/button.py new file mode 100644 index 00000000000..9faa9f22c4c --- /dev/null +++ b/homeassistant/components/thermopro/button.py @@ -0,0 +1,157 @@ +"""Thermopro button platform.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData, ThermoProDevice + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_track_unavailable, +) +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import now + +from .const import DOMAIN, SIGNAL_AVAILABILITY_UPDATED, SIGNAL_DATA_UPDATED + +PARALLEL_UPDATES = 1 # one connection at a time + + +@dataclass(kw_only=True, frozen=True) +class ThermoProButtonEntityDescription(ButtonEntityDescription): + """Describe a ThermoPro button entity.""" + + press_action_fn: Callable[[HomeAssistant, str], Coroutine[None, Any, Any]] + + +async def _async_set_datetime(hass: HomeAssistant, address: str) -> None: + """Set Date&Time for a given device.""" + ble_device = async_ble_device_from_address(hass, address, connectable=True) + assert ble_device is not None + await ThermoProDevice(ble_device).set_datetime(now(), am_pm=False) + + +BUTTON_ENTITIES: tuple[ThermoProButtonEntityDescription, ...] = ( + ThermoProButtonEntityDescription( + key="datetime", + translation_key="set_datetime", + icon="mdi:calendar-clock", + entity_category=EntityCategory.CONFIG, + press_action_fn=_async_set_datetime, + ), +) + +MODELS_THAT_SUPPORT_BUTTONS = {"TP358", "TP393"} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the thermopro button platform.""" + address = entry.unique_id + assert address is not None + availability_signal = f"{SIGNAL_AVAILABILITY_UPDATED}_{entry.entry_id}" + entity_added = False + + @callback + def _async_on_data_updated( + data: ThermoProBluetoothDeviceData, + service_info: BluetoothServiceInfoBleak, + update: SensorUpdate, + ) -> None: + nonlocal entity_added + sensor_device_info = update.devices[data.primary_device_id] + if sensor_device_info.model not in MODELS_THAT_SUPPORT_BUTTONS: + return + + if not entity_added: + name = sensor_device_info.name + assert name is not None + entity_added = True + async_add_entities( + ThermoProButtonEntity( + description=description, + data=data, + availability_signal=availability_signal, + address=address, + ) + for description in BUTTON_ENTITIES + ) + + if service_info.connectable: + async_dispatcher_send(hass, availability_signal, True) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", _async_on_data_updated + ) + ) + + +class ThermoProButtonEntity(ButtonEntity): + """Representation of a ThermoPro button entity.""" + + _attr_has_entity_name = True + entity_description: ThermoProButtonEntityDescription + + def __init__( + self, + description: ThermoProButtonEntityDescription, + data: ThermoProBluetoothDeviceData, + availability_signal: str, + address: str, + ) -> None: + """Initialize the thermopro button entity.""" + self.entity_description = description + self._address = address + self._availability_signal = availability_signal + self._attr_unique_id = f"{address}-{description.key}" + self._attr_device_info = dr.DeviceInfo( + name=data.get_device_name(), + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + + async def async_added_to_hass(self) -> None: + """Connect availability dispatcher.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._availability_signal, + self._async_on_availability_changed, + ) + ) + self.async_on_remove( + async_track_unavailable( + self.hass, self._async_on_unavailable, self._address, connectable=True + ) + ) + + @callback + def _async_on_unavailable(self, _: BluetoothServiceInfoBleak) -> None: + self._async_on_availability_changed(False) + + @callback + def _async_on_availability_changed(self, available: bool) -> None: + self._attr_available = available + self.async_write_ha_state() + + async def async_press(self) -> None: + """Execute the press action for the entity.""" + await self.entity_description.press_action_fn(self.hass, self._address) diff --git a/homeassistant/components/thermopro/config_flow.py b/homeassistant/components/thermopro/config_flow.py index 4d080c6e074..4c6d59473c2 100644 --- a/homeassistant/components/thermopro/config_flow.py +++ b/homeassistant/components/thermopro/config_flow.py @@ -72,7 +72,7 @@ class ThermoProConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/thermopro/const.py b/homeassistant/components/thermopro/const.py index 343729442cf..7d2170f8cf9 100644 --- a/homeassistant/components/thermopro/const.py +++ b/homeassistant/components/thermopro/const.py @@ -1,3 +1,6 @@ """Constants for the ThermoPro Bluetooth integration.""" DOMAIN = "thermopro" + +SIGNAL_DATA_UPDATED = f"{DOMAIN}_service_info_updated" +SIGNAL_AVAILABILITY_UPDATED = f"{DOMAIN}_availability_updated" diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 51348afb0a4..6027e4bc99c 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.10.0"] + "requirements": ["thermopro-ble==0.11.0"] } diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 4aca6101685..853f00f2dd5 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -9,7 +9,6 @@ from thermopro_ble import ( Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, @@ -23,6 +22,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -110,8 +110,8 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoPro BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 4e12a84b653..5789de410b2 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -17,5 +17,12 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "button": { + "set_datetime": { + "name": "Set Date&Time" + } + } } } diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 7dc845ecf60..7ce0dfb9993 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thethingsnetwork/coordinator.py b/homeassistant/components/thethingsnetwork/coordinator.py index 64608c2f064..78ffceecf84 100644 --- a/homeassistant/components/thethingsnetwork/coordinator.py +++ b/homeassistant/components/thethingsnetwork/coordinator.py @@ -19,11 +19,14 @@ _LOGGER = logging.getLogger(__name__) class TTNCoordinator(DataUpdateCoordinator[TTNClient.DATA_TYPE]): """TTN coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, # Name of the data. For logging purposes. name=f"TheThingsNetwork_{entry.data[CONF_APP_ID]}", # Polling interval. Will only be polled if there are subscribers. diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 25dd2f1e1eb..ba512d07f18 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -7,7 +7,7 @@ from ttn_client import TTNSensorValue from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import CONF_APP_ID, DOMAIN @@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for TTN.""" diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index f5a4fcef8fd..8b3eb7b53c4 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -2,12 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to The Things Network v3 App", - "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.", + "title": "Connect to The Things Network v3", + "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.", "data": { - "hostname": "[%key:common::config_flow::data::host%]", + "host": "[%key:common::config_flow::data::host%]", "app_id": "Application ID", - "access_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/thingspeak/__init__.py b/homeassistant/components/thingspeak/__init__.py index fdf06a9709a..1798e4f1de0 100644 --- a/homeassistant/components/thingspeak/__init__.py +++ b/homeassistant/components/thingspeak/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import event, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index 4d28912e20d..ccdc1ada48e 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 76c7cdb0db2..8397eeedc23 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index abf3e604472..f003264b6d7 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -4,8 +4,8 @@ from __future__ import annotations import logging import re -import telnetlib # pylint: disable=deprecated-module +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 568b76d4999..bf202a50c34 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -4,8 +4,9 @@ from __future__ import annotations from typing import Any -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,7 +29,7 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="Thread", data={}) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Set up because the user has border routers.""" await self._async_handle_discovery_without_unique_id() diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index fc95e524181..1b4ae7ba01f 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -8,7 +8,7 @@ from datetime import datetime import logging from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from python_otbr_api import tlv_parser from python_otbr_api.tlv_parser import MeshcopTLVType diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 65d4c9d044c..868ced022b8 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.7.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3d52d2225be..3227f030812 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -33,7 +33,10 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -90,7 +93,7 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize threshold config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 9b5c7ee1168..424b35b963b 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ssl as ssl_util diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 78841f9db91..2de9ebd1ec6 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -33,11 +33,17 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + tibber_connection: tibber.Tibber, + ) -> None: """Initialize the data handler.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Tibber {tibber_connection.name}", update_interval=timedelta(minutes=20), ) diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index fdeeeba68ef..df6541591e0 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -12,13 +12,15 @@ from homeassistant.components.notify import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as TIBBER_DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber notification entity.""" async_add_entities([TibberNotificationEntity(entry.entry_id)]) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index c1ec7bf2a9e..9f87b8a8490 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -33,7 +33,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -261,7 +261,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber sensor.""" @@ -285,7 +287,7 @@ async def async_setup_entry( if home.has_active_subscription: entities.append(TibberSensorElPrice(home)) if coordinator is None: - coordinator = TibberDataCoordinator(hass, tibber_connection) + coordinator = TibberDataCoordinator(hass, entry, tibber_connection) entities.extend( TibberDataSensor(home, coordinator, entity_description) for entity_description in SENSORS @@ -531,7 +533,7 @@ class TibberRtEntityCreator: def __init__( self, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tibber_home: tibber.TibberHome, entity_registry: er.EntityRegistry, ) -> None: diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py index 26ffc0e7b6d..a3961cbb569 100644 --- a/homeassistant/components/tikteck/light.py +++ b/homeassistant/components/tikteck/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tile/binary_sensor.py b/homeassistant/components/tile/binary_sensor.py index 1719c793c0e..6abc80732a6 100644 --- a/homeassistant/components/tile/binary_sensor.py +++ b/homeassistant/components/tile/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TileConfigEntry, TileCoordinator from .entity import TileEntity @@ -35,7 +35,9 @@ ENTITIES: tuple[TileBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TileConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tile binary sensors.""" diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 6a0aae1bdf9..66a3b8b0e27 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_utc from .coordinator import TileConfigEntry, TileCoordinator @@ -26,7 +26,9 @@ ATTR_VOIP_STATE = "voip_state" async def async_setup_entry( - hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TileConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tile device trackers.""" diff --git a/homeassistant/components/tilt_ble/config_flow.py b/homeassistant/components/tilt_ble/config_flow.py index 5c1f9721aae..b4a3235c60f 100644 --- a/homeassistant/components/tilt_ble/config_flow.py +++ b/homeassistant/components/tilt_ble/config_flow.py @@ -72,7 +72,7 @@ class TiltConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py index e8e1f902cd9..411484cf2fe 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -86,7 +86,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tilt Hydrometer BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 473472356d4..60e55c214fe 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -6,7 +6,7 @@ from datetime import time, timedelta import logging from typing import final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 245d10bebba..f05244e7680 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -17,11 +17,14 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import OPTION_TYPES @@ -56,7 +59,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Time & Date sensor.""" diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 19b1de427ef..3cf8307e9b3 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -19,15 +19,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -375,6 +374,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_cancel(self) -> None: """Cancel a timer.""" + if self._state == STATUS_IDLE: + return + if self._listener: self._listener() self._listener = None @@ -390,13 +392,15 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_finish(self) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE or self._end is None: + if self._state == STATUS_IDLE: return if self._listener: self._listener() self._listener = None end = self._end + if end is None: + end = dt_util.utcnow().replace(microsecond=0) self._state = STATUS_IDLE self._end = None self._remaining = None diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 126c3128f91..cbf3b073578 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 3ac90b5578c..1ab34861a6e 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -24,7 +24,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -59,7 +62,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Times of the Day config entry.""" if hass.config.time_zone is None: diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index e4bc549a16b..937187c1c6f 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -8,7 +8,7 @@ import datetime import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import frontend, websocket_api diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 62f9fafc02a..2e2873353c6 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -22,9 +22,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -113,7 +116,9 @@ SCAN_INTERVAL = timedelta(minutes=1) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Todoist calendar platform config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] @@ -541,9 +546,8 @@ class TodoistProjectData: return None # All task Labels (optional parameter). - task[LABELS] = [ - label.name for label in self._labels if label.name in data.labels - ] + labels = data.labels or [] + task[LABELS] = [label.name for label in self._labels if label.name in labels] if self._label_whitelist and ( not any(label in task[LABELS] for label in self._label_whitelist) ): diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index 72d76108353..791f5642aad 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.1.2"] + "requirements": ["todoist-api-python==2.1.7"] } diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 721b491bbf5..68f22b51c47 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -55,7 +55,7 @@ }, "assignee": { "name": "Assignee", - "description": "A members username of a shared project to assign this task to." + "description": "The username of a shared project's member to assign this task to." }, "priority": { "name": "Priority", @@ -63,11 +63,11 @@ }, "due_date_string": { "name": "Due date string", - "description": "The day this task is due, in natural language." + "description": "The time this task is due, in natural language." }, "due_date_lang": { "name": "Due date language", - "description": "The language of due_date_string." + "description": "The language of 'Due date string'." }, "due_date": { "name": "Due date", @@ -75,15 +75,15 @@ }, "reminder_date_string": { "name": "Reminder date string", - "description": "When should user be reminded of this task, in natural language." + "description": "When the user should be reminded of this task, in natural language." }, "reminder_date_lang": { "name": "Reminder date language", - "description": "The language of reminder_date_string." + "description": "The language of 'Reminder date string'." }, "reminder_date": { "name": "Reminder date", - "description": "When should user be reminded of this task, in format YYYY-MM-DDTHH:MM:SS, in UTC timezone." + "description": "When the user should be reminded of this task, in format YYYY-MM-DDTHH:MM:SS, in UTC timezone." } } } diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 490e4ad9f1a..202c51fb4c0 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -14,7 +14,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -23,7 +23,9 @@ from .coordinator import TodoistCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Todoist todo platform config entry.""" coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py index 845f8ed22e3..cb3ba46b604 100644 --- a/homeassistant/components/tolo/binary_sensor.py +++ b/homeassistant/components/tolo/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py index b7c4362ca7b..9e4c8c84be9 100644 --- a/homeassistant/components/tolo/button.py +++ b/homeassistant/components/tolo/button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -16,7 +16,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 5e6428525c1..0df8635fca9 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -23,7 +23,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -33,7 +33,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/config_flow.py b/homeassistant/components/tolo/config_flow.py index d5d7e33a5e0..fed4ff332fc 100644 --- a/homeassistant/components/tolo/config_flow.py +++ b/homeassistant/components/tolo/config_flow.py @@ -8,10 +8,10 @@ from typing import Any from tololib import ToloClient, ToloCommunicationError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -61,7 +61,7 @@ class ToloSaunaConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) diff --git a/homeassistant/components/tolo/coordinator.py b/homeassistant/components/tolo/coordinator.py index 632cc819f5a..729073b16c4 100644 --- a/homeassistant/components/tolo/coordinator.py +++ b/homeassistant/components/tolo/coordinator.py @@ -28,6 +28,8 @@ class ToloSaunaData(NamedTuple): class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): """DataUpdateCoordinator for TOLO Sauna.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize ToloSaunaUpdateCoordinator.""" self.client = ToloClient( @@ -38,6 +40,7 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): super().__init__( hass=hass, logger=_LOGGER, + config_entry=entry, name=f"{entry.title} ({entry.data[CONF_HOST]}) Data Update Coordinator", update_interval=timedelta(seconds=5), ) diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 9e48778b507..7bddf775143 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fan controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py index eeb37305fe8..9ccd4a8e407 100644 --- a/homeassistant/components/tolo/light.py +++ b/homeassistant/components/tolo/light.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/manifest.json b/homeassistant/components/tolo/manifest.json index 14125a857f6..613fc810683 100644 --- a/homeassistant/components/tolo/manifest.json +++ b/homeassistant/components/tolo/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/tolo", "iot_class": "local_polling", "loggers": ["tololib"], - "requirements": ["tololib==1.1.0"] + "requirements": ["tololib==1.2.2"] } diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 73505c5b251..902fb749d23 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -68,7 +68,7 @@ NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py index fee1ac1774e..b08f37e40ae 100644 --- a/homeassistant/components/tolo/select.py +++ b/homeassistant/components/tolo/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, AromaTherapySlot, LampMode from .coordinator import ToloSaunaUpdateCoordinator @@ -54,7 +54,7 @@ SELECTS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 0e94ec0ae1e..e97211c8e40 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -89,7 +89,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up (non-binary, general) sensors for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py index d39dd17f0f3..ce863053e26 100644 --- a/homeassistant/components/tolo/switch.py +++ b/homeassistant/components/tolo/switch.py @@ -11,7 +11,7 @@ from tololib import ToloClient, ToloStatus from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -45,7 +45,7 @@ SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index dfa8d2bd4e1..2cef5eea0cf 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_HTTP_ID = "http_id" diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 73f62735e06..7d6b9ed3f73 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will not use the class's lat and long so we can pass in garbage # lats and longs api = TomorrowioV4(api_key, 361.0, 361.0, unit_system="metric", session=session) - coordinator = TomorrowioDataUpdateCoordinator(hass, api) + coordinator = TomorrowioDataUpdateCoordinator(hass, entry, api) hass.data[DOMAIN][api_key] = coordinator await coordinator.async_setup_entry(entry) diff --git a/homeassistant/components/tomorrowio/coordinator.py b/homeassistant/components/tomorrowio/coordinator.py index 60b997e4c0d..2a6b3675792 100644 --- a/homeassistant/components/tomorrowio/coordinator.py +++ b/homeassistant/components/tomorrowio/coordinator.py @@ -116,14 +116,23 @@ def async_set_update_interval( class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an object to hold Tomorrow.io data.""" - def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: TomorrowioV4 + ) -> None: """Initialize.""" self._api = api self.data = {CURRENT: {}, FORECASTS: {}} self.entry_id_to_location_dict: dict[str, str] = {} self._coordinator_ready: asyncio.Event | None = None - super().__init__(hass, LOGGER, name=f"{DOMAIN}_{self._api.api_key_masked}") + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{self._api.api_key_masked}", + ) def add_entry_to_location_dict(self, entry: ConfigEntry) -> None: """Add an entry to the location dict.""" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 7ff17961b58..08e1991d831 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -34,7 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -328,7 +328,7 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 92b09500e7b..0a070a1b33b 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util @@ -66,7 +66,7 @@ from .entity import TomorrowioEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 43c787b2301..1c56b780c0f 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -89,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - coordinator = ToonDataUpdateCoordinator(hass, entry=entry, session=session) + coordinator = ToonDataUpdateCoordinator(hass, entry, session) await coordinator.toon.activate_agreement( agreement_id=entry.data[CONF_AGREEMENT_ID] ) diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index 11b13a32ee5..eff8aed0a20 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator @@ -25,7 +25,9 @@ from .entity import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon binary sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 0c2e5b9b232..5538a0abd91 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -24,7 +24,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ToonDataUpdateCoordinator from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN @@ -33,7 +33,9 @@ from .helpers import toon_exception_handler async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 586eca34959..894b4c91334 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -28,12 +28,13 @@ _LOGGER = logging.getLogger(__name__) class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): """Class to manage fetching Toon data from single endpoint.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session + self, hass: HomeAssistant, entry: ConfigEntry, session: OAuth2Session ) -> None: """Initialize global Toon data updater.""" self.session = session - self.entry = entry async def async_token_refresh() -> str: await session.async_ensure_token_valid() @@ -46,49 +47,55 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): ) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) async def register_webhook(self, event: Event | None = None) -> None: """Register a webhook with Toon to get live updates.""" - if CONF_WEBHOOK_ID not in self.entry.data: - data = {**self.entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} - self.hass.config_entries.async_update_entry(self.entry, data=data) + if CONF_WEBHOOK_ID not in self.config_entry.data: + data = {**self.config_entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} + self.hass.config_entries.async_update_entry(self.config_entry, data=data) if cloud.async_active_subscription(self.hass): - if CONF_CLOUDHOOK_URL not in self.entry.data: + if CONF_CLOUDHOOK_URL not in self.config_entry.data: try: webhook_url = await cloud.async_create_cloudhook( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) except cloud.CloudNotConnected: webhook_url = webhook.async_generate_url( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) else: - data = {**self.entry.data, CONF_CLOUDHOOK_URL: webhook_url} - self.hass.config_entries.async_update_entry(self.entry, data=data) + data = {**self.config_entry.data, CONF_CLOUDHOOK_URL: webhook_url} + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) else: - webhook_url = self.entry.data[CONF_CLOUDHOOK_URL] + webhook_url = self.config_entry.data[CONF_CLOUDHOOK_URL] else: webhook_url = webhook.async_generate_url( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) # Ensure the webhook is not registered already - webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(self.hass, self.config_entry.data[CONF_WEBHOOK_ID]) webhook_register( self.hass, DOMAIN, "Toon", - self.entry.data[CONF_WEBHOOK_ID], + self.config_entry.data[CONF_WEBHOOK_ID], self.handle_webhook, ) try: await self.toon.subscribe_webhook( - application_id=self.entry.entry_id, url=webhook_url + application_id=self.config_entry.entry_id, url=webhook_url ) _LOGGER.debug("Registered Toon webhook: %s", webhook_url) except ToonError as err: @@ -131,14 +138,14 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): async def unregister_webhook(self, event: Event | None = None) -> None: """Remove / Unregister webhook for toon.""" _LOGGER.debug( - "Unregistering Toon webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] + "Unregistering Toon webhook (%s)", self.config_entry.data[CONF_WEBHOOK_ID] ) try: - await self.toon.unsubscribe_webhook(self.entry.entry_id) + await self.toon.unsubscribe_webhook(self.config_entry.entry_id) except ToonError as err: _LOGGER.error("Failed unregistering Toon webhook - %s", err) - webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(self.hass, self.config_entry.data[CONF_WEBHOOK_ID]) async def _async_update_data(self) -> Status: """Fetch data from Toon.""" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 09f36c88079..e5b155b409b 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN from .coordinator import ToonDataUpdateCoordinator @@ -36,7 +36,9 @@ from .entity import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Toon sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index deb2a12f2d0..d59a542d4d8 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -15,7 +15,7 @@ from toonapi import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator @@ -24,7 +24,9 @@ from .helpers import toon_exception_handler async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon switches based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 543046fac1c..8d4183e2961 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_EMAIL, CONF_NAME, DEGREE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 9f291ea15a6..a481fd41c84 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -3,18 +3,15 @@ from total_connect_client.client import TotalConnectClient from total_connect_client.exceptions import AuthenticationError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from .const import AUTO_BYPASS, CONF_USERCODES -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON] -type TotalConnectConfigEntry = ConfigEntry[TotalConnectDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: TotalConnectConfigEntry @@ -41,7 +38,7 @@ async def async_setup_entry( "TotalConnect authentication failed during setup" ) from exception - coordinator = TotalConnectDataUpdateCoordinator(hass, client) + coordinator = TotalConnectDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 48ba78acc92..9ed29ea01c8 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -12,14 +12,13 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CODE_REQUIRED, DOMAIN -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity SERVICE_ALARM_ARM_AWAY_INSTANT = "arm_away_instant" @@ -27,7 +26,9 @@ SERVICE_ALARM_ARM_HOME_INSTANT = "arm_home_instant" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TotalConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TotalConnect alarm panels based on a config entry.""" coordinator = entry.runtime_data @@ -73,7 +74,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) - self._partition_id = partition_id + self._partition_id = int(partition_id) self._partition = self._location.partitions[partition_id] """ @@ -81,7 +82,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): for most users with new support for partitions. Add _# for partition 2 and beyond. """ - if partition_id == 1: + if int(partition_id) == 1: self._attr_name = None self._attr_unique_id = str(location.location_id) else: diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 9a3c2558999..2f3802dc9a6 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -12,12 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity LOW_BATTERY = "low_battery" @@ -119,7 +118,9 @@ LOCATION_BINARY_SENSORS: tuple[TotalConnectAlarmBinarySensorEntityDescription, . async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TotalConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TotalConnect device sensors based on a config entry.""" sensors: list = [] diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py index e228f03ec6b..eb85dcce1bf 100644 --- a/homeassistant/components/totalconnect/button.py +++ b/homeassistant/components/totalconnect/button.py @@ -7,12 +7,11 @@ from total_connect_client.location import TotalConnectLocation from total_connect_client.zone import TotalConnectZone from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity @@ -38,7 +37,9 @@ PANEL_BUTTONS: tuple[TotalConnectButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TotalConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TotalConnect buttons based on a config entry.""" buttons: list = [] diff --git a/homeassistant/components/totalconnect/coordinator.py b/homeassistant/components/totalconnect/coordinator.py index 9b500db1951..673c168d204 100644 --- a/homeassistant/components/totalconnect/coordinator.py +++ b/homeassistant/components/totalconnect/coordinator.py @@ -20,17 +20,28 @@ from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +type TotalConnectConfigEntry = ConfigEntry[TotalConnectDataUpdateCoordinator] + class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to fetch data from TotalConnect.""" - config_entry: ConfigEntry + config_entry: TotalConnectConfigEntry - def __init__(self, hass: HomeAssistant, client: TotalConnectClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TotalConnectConfigEntry, + client: TotalConnectClient, + ) -> None: """Initialize.""" self.client = client super().__init__( - hass, logger=_LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL + hass, + logger=_LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, ) async def _async_update_data(self) -> None: diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index 85f52ccc670..f42ed5e44c3 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -5,9 +5,10 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .coordinator import TotalConnectConfigEntry + TO_REDACT = [ "username", "Password", @@ -22,7 +23,7 @@ TO_REDACT = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: TotalConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client = config_entry.runtime_data.client diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 33306a7adba..6aff1ea392b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.12"] + "requirements": ["total-connect-client==2025.1.4"] } diff --git a/homeassistant/components/totalconnect/quality_scale.yaml b/homeassistant/components/totalconnect/quality_scale.yaml index fb0f1e5098a..2ec54250b72 100644 --- a/homeassistant/components/totalconnect/quality_scale.yaml +++ b/homeassistant/components/totalconnect/quality_scale.yaml @@ -1,11 +1,11 @@ rules: # Bronze - config-flow: todo + config-flow: done test-before-configure: done unique-config-entry: done config-flow-test-coverage: todo runtime-data: done - test-before-setup: todo + test-before-setup: done appropriate-polling: done entity-unique-id: done has-entity-name: done @@ -15,7 +15,7 @@ rules: common-modules: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done @@ -47,13 +47,11 @@ rules: discovery-update-info: todo repair-issues: todo docs-use-cases: done - - # stopped here.... - docs-supported-devices: todo - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done docs-examples: done # Platinum diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 004056ef9ac..daf720084a5 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -2,21 +2,36 @@ "config": { "step": { "user": { + "title": "Total Connect 2.0 Account Credentials", + "description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The Total Connect username", + "password": "The Total Connect password" } }, "locations": { "title": "Location Usercodes", "description": "Enter the usercode for this user at location {location_id}", "data": { - "usercode": "Usercode" + "usercodes": "Usercode" + }, + "data_description": { + "usercodes": "The usercode is usually a 4 digit number" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Total Connect needs to re-authenticate your account" + "description": "Total Connect needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::totalconnect::config::step::user::data_description::password%]" + } } }, "error": { @@ -36,6 +51,10 @@ "data": { "auto_bypass_low_battery": "Auto bypass low battery", "code_required": "Require user to enter code for alarm actions" + }, + "data_description": { + "auto_bypass_low_battery": "If enabled, Total Connect zones will immediately be bypassed when they report low battery. This option helps because zones tend to report low battery in the middle of the night. The downside of this option is that when the alarm system is armed, the bypassed zone will not be monitored.", + "code_required": "If enabled, you must enter the user code to arm or disarm the alarm" } } } diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index e9d27341cb7..86526f4718b 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, NamedTuple -from pytouchline import PyTouchline +from pytouchline_extended import PyTouchline import voluptuous as vol from homeassistant.components.climate import ( @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -53,12 +53,13 @@ def setup_platform( """Set up the Touchline devices.""" host = config[CONF_HOST] - py_touchline = PyTouchline() - number_of_devices = int(py_touchline.get_number_of_devices(host)) - add_entities( - (Touchline(PyTouchline(device_id)) for device_id in range(number_of_devices)), - True, - ) + py_touchline = PyTouchline(url=host) + number_of_devices = int(py_touchline.get_number_of_devices()) + devices = [ + Touchline(PyTouchline(id=device_id, url=host)) + for device_id in range(number_of_devices) + ] + add_entities(devices, True) class Touchline(ClimateEntity): diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json index c003cca97a4..6d25462408b 100644 --- a/homeassistant/components/touchline/manifest.json +++ b/homeassistant/components/touchline/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pytouchline"], "quality_scale": "legacy", - "requirements": ["pytouchline==0.7"] + "requirements": ["pytouchline_extended==0.4.5"] } diff --git a/homeassistant/components/touchline_sl/__init__.py b/homeassistant/components/touchline_sl/__init__.py index 45a85185673..ba1da06ed5a 100644 --- a/homeassistant/components/touchline_sl/__init__.py +++ b/homeassistant/components/touchline_sl/__init__.py @@ -6,18 +6,15 @@ import asyncio from pytouchlinesl import TouchlineSL -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import TouchlineSLModuleCoordinator +from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] -type TouchlineSLConfigEntry = ConfigEntry[list[TouchlineSLModuleCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) -> bool: """Set up Roth Touchline SL from a config entry.""" @@ -26,7 +23,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) ) coordinators: list[TouchlineSLModuleCoordinator] = [ - TouchlineSLModuleCoordinator(hass, module) for module in await account.modules() + TouchlineSLModuleCoordinator(hass, entry, module) + for module in await account.modules() ] await asyncio.gather( diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py index 8a0ffc4cd86..7c5ea4ea9ca 100644 --- a/homeassistant/components/touchline_sl/climate.py +++ b/homeassistant/components/touchline_sl/climate.py @@ -10,17 +10,16 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TouchlineSLConfigEntry -from .coordinator import TouchlineSLModuleCoordinator +from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator from .entity import TouchlineSLZoneEntity async def async_setup_entry( hass: HomeAssistant, entry: TouchlineSLConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Touchline devices.""" coordinators = entry.runtime_data diff --git a/homeassistant/components/touchline_sl/coordinator.py b/homeassistant/components/touchline_sl/coordinator.py index cd74ba6130f..dce616a81b3 100644 --- a/homeassistant/components/touchline_sl/coordinator.py +++ b/homeassistant/components/touchline_sl/coordinator.py @@ -10,6 +10,7 @@ from pytouchlinesl import Module, Zone from pytouchlinesl.client import RothAPIError from pytouchlinesl.client.models import GlobalScheduleModel +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,14 +27,22 @@ class TouchlineSLModuleData: schedules: dict[str, GlobalScheduleModel] +type TouchlineSLConfigEntry = ConfigEntry[list[TouchlineSLModuleCoordinator]] + + class TouchlineSLModuleCoordinator(DataUpdateCoordinator[TouchlineSLModuleData]): """A coordinator to manage the fetching of Touchline SL data.""" - def __init__(self, hass: HomeAssistant, module: Module) -> None: + config_entry: TouchlineSLConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: TouchlineSLConfigEntry, module: Module + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=f"Touchline SL ({module.name})", update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e2a2f99517f..31bdcc5481c 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Iterable from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from aiohttp import ClientSession from kasa import ( @@ -18,11 +18,9 @@ from kasa import ( KasaException, ) from kasa.httpclient import get_cookie_jar -from kasa.iot import IotStrip from homeassistant import config_entries from homeassistant.components import network -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ALIAS, CONF_AUTHENTICATION, @@ -59,10 +57,7 @@ from .const import ( DOMAIN, PLATFORMS, ) -from .coordinator import TPLinkDataUpdateCoordinator -from .models import TPLinkData - -type TPLinkConfigEntry = ConfigEntry[TPLinkData] +from .coordinator import TPLinkConfigEntry, TPLinkData, TPLinkDataUpdateCoordinator DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -178,9 +173,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo if not credentials and entry_credentials_hash: data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH} hass.config_entries.async_update_entry(entry, data=data) - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "connect", + "exc": str(ex), + }, + ) from ex device_credentials_hash = device.credentials_hash @@ -212,21 +221,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo # wait for the next discovery to find the device at its new address # and update the config entry so we do not mix up devices. raise ConfigEntryNotReady( - f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + translation_domain=DOMAIN, + translation_key="unexpected_device", + translation_placeholders={ + "host": host, + # all entries have a unique id + "expected": cast(str, entry.unique_id), + "found": found_mac, + }, ) - parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) - child_coordinators: list[TPLinkDataUpdateCoordinator] = [] - - # The iot HS300 allows a limited number of concurrent requests and fetching the - # emeter information requires separate ones so create child coordinators here. - if isinstance(device, IotStrip): - child_coordinators = [ - # The child coordinators only update energy data so we can - # set a longer update interval to avoid flooding the device - TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60)) - for child in device.children - ] + parent_coordinator = TPLinkDataUpdateCoordinator( + hass, device, timedelta(seconds=5), entry + ) camera_creds: Credentials | None = None if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS): @@ -235,9 +242,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo ) live_view = entry.data.get(CONF_LIVE_VIEW) - entry.runtime_data = TPLinkData( - parent_coordinator, child_coordinators, camera_creds, live_view - ) + entry.runtime_data = TPLinkData(parent_coordinator, camera_creds, live_view) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -263,7 +268,7 @@ def legacy_device_id(device: Device) -> str: return device_id.split("_")[1] -def get_device_name(device: Device, parent: Device | None = None) -> str: +def get_device_name(device: Device, parent: Device | None = None) -> str | None: """Get a name for the device. alias can be none on some devices.""" if device.alias: return device.alias @@ -278,7 +283,7 @@ def get_device_name(device: Device, parent: Device | None = None) -> str: ] suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else "" return f"{device.device_type.value.capitalize()}{suffix}" - return f"Unnamed {device.model}" + return None async def get_credentials(hass: HomeAssistant) -> Credentials | None: @@ -325,7 +330,9 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None ) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TPLinkConfigEntry +) -> bool: """Migrate old entry.""" entry_version = config_entry.version entry_minor_version = config_entry.minor_version diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index f3a7e7a7ce7..38935595fe2 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -8,12 +8,13 @@ from typing import Final, cast from kasa import Feature from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -23,14 +24,21 @@ from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescripti class TPLinkBinarySensorEntityDescription( BinarySensorEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based binary sensor entity description.""" +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + BINARY_SENSOR_DESCRIPTIONS: Final = ( TPLinkBinarySensorEntityDescription( key="overheated", device_class=BinarySensorDeviceClass.PROBLEM, ), + TPLinkBinarySensorEntityDescription( + key="overloaded", + device_class=BinarySensorDeviceClass.PROBLEM, + ), TPLinkBinarySensorEntityDescription( key="battery_low", device_class=BinarySensorDeviceClass.BATTERY, @@ -39,11 +47,6 @@ BINARY_SENSOR_DESCRIPTIONS: Final = ( key="cloud_connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), - # To be replaced & disabled per default by the upcoming update platform. - TPLinkBinarySensorEntityDescription( - key="update_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), TPLinkBinarySensorEntityDescription( key="temperature_warning", ), @@ -70,24 +73,33 @@ BINARYSENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in BINARY_SENSOR_DESCRI async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.BinarySensor, - entity_class=TPLinkBinarySensorEntity, - descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.BinarySensor, + entity_class=TPLinkBinarySensorEntity, + descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, + platform_domain=BINARY_SENSOR_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity): diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 753efcf89f4..145adb79185 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -15,10 +15,10 @@ from homeassistant.components.button import ( ) from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry -from .deprecate import DeprecatedInfo, async_cleanup_deprecated +from .deprecate import DeprecatedInfo from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -29,6 +29,10 @@ class TPLinkButtonEntityDescription( """Base class for a TPLink feature based button entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: Final = [ TPLinkButtonEntityDescription( key="test_alarm", @@ -66,6 +70,23 @@ BUTTON_DESCRIPTIONS: Final = [ key="tilt_down", available_fn=lambda dev: dev.is_on, ), + TPLinkButtonEntityDescription(key="pair"), + TPLinkButtonEntityDescription(key="unpair"), + TPLinkButtonEntityDescription( + key="main_brush_reset", + ), + TPLinkButtonEntityDescription( + key="side_brush_reset", + ), + TPLinkButtonEntityDescription( + key="sensor_reset", + ), + TPLinkButtonEntityDescription( + key="filter_reset", + ), + TPLinkButtonEntityDescription( + key="charging_contacts_reset", + ), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} @@ -74,25 +95,32 @@ BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Action, - entity_class=TPLinkButtonEntity, - descriptions=BUTTON_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Action, + entity_class=TPLinkButtonEntity, + descriptions=BUTTON_DESCRIPTIONS_MAP, + platform_domain=BUTTON_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index 01b47db7082..7b59678da8e 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -7,11 +7,11 @@ import time from aiohttp import web from haffmpeg.camera import CameraMjpeg -from kasa import Credentials, Device, Module, StreamResolution -from kasa.smartcam.modules import Camera as CameraModule +from kasa import Device, Module, StreamResolution from homeassistant.components import ffmpeg, stream from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, Camera, CameraEntityDescription, CameraEntityFeature, @@ -19,15 +19,19 @@ from homeassistant.components.camera import ( from homeassistant.config_entries import ConfigFlowContext from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TPLinkConfigEntry, legacy_device_id +from . import TPLinkConfigEntry from .const import CONF_CAMERA_CREDENTIALS from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, TPLinkModuleEntityDescription +from .entity import CoordinatedTPLinkModuleEntity, TPLinkModuleEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class TPLinkCameraEntityDescription( @@ -41,6 +45,13 @@ CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( key="live_view", translation_key="live_view", available_fn=lambda dev: dev.is_on, + exists_fn=lambda dev, entry: ( + (rtd := entry.runtime_data) is not None + and rtd.live_view is True + and (cam_creds := rtd.camera_credentials) is not None + and (cm := dev.modules.get(Module.Camera)) is not None + and cm.stream_rtsp_url(cam_creds) is not None + ), ), ) @@ -48,32 +59,35 @@ CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up camera entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - camera_credentials = data.camera_credentials - live_view = data.live_view - ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - async_add_entities( - TPLinkCameraEntity( - device, - parent_coordinator, - description, - camera_module=camera_module, - parent=None, - ffmpeg_manager=ffmpeg_manager, - camera_credentials=camera_credentials, + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkCameraEntity, + descriptions=CAMERA_DESCRIPTIONS, + platform_domain=CAMERA_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, ) - for description in CAMERA_DESCRIPTIONS - if (camera_module := device.modules.get(Module.Camera)) and live_view - ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): +class TPLinkCameraEntity(CoordinatedTPLinkModuleEntity, Camera): """Representation of a TPLink camera.""" IMAGE_INTERVAL = 5 * 60 @@ -82,36 +96,38 @@ class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera): entity_description: TPLinkCameraEntityDescription + _ffmpeg_manager: ffmpeg.FFmpegManager + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, description: TPLinkCameraEntityDescription, *, - camera_module: CameraModule, parent: Device | None = None, - ffmpeg_manager: ffmpeg.FFmpegManager, - camera_credentials: Credentials | None, ) -> None: """Initialize a TPlink camera.""" - self.entity_description = description - self._camera_module = camera_module - self._video_url = camera_module.stream_rtsp_url( - camera_credentials, stream_resolution=StreamResolution.SD + super().__init__(device, coordinator, description=description, parent=parent) + Camera.__init__(self) + + self._camera_module = device.modules[Module.Camera] + self._camera_credentials = ( + coordinator.config_entry.runtime_data.camera_credentials + ) + self._video_url = self._camera_module.stream_rtsp_url( + self._camera_credentials, stream_resolution=StreamResolution.SD ) self._image: bytes | None = None - super().__init__(device, coordinator, parent=parent) - Camera.__init__(self) - self._ffmpeg_manager = ffmpeg_manager self._image_lock = asyncio.Lock() self._last_update: float = 0 - self._camera_credentials = camera_credentials self._can_stream = True self._http_mpeg_stream_running = False - def _get_unique_id(self) -> str: - """Return unique ID for the entity.""" - return f"{legacy_device_id(self._device)}-{self.entity_description.key}" + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass) @callback def _async_update_attrs(self) -> bool: diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index f53a0d093ac..66037d7476e 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -2,59 +2,104 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any, cast -from kasa import Device, DeviceType +from kasa import Device, Module from kasa.smart.modules.temperaturecontrol import ThermostatState from homeassistant.components.climate import ( ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, ) -from homeassistant.const import PRECISION_TENTHS +from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TPLinkConfigEntry -from .const import UNIT_MAPPING +from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN, UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 # Upstream state to HVACAction STATE_TO_ACTION = { ThermostatState.Idle: HVACAction.IDLE, ThermostatState.Heating: HVACAction.HEATING, ThermostatState.Off: HVACAction.OFF, + ThermostatState.Calibrating: HVACAction.IDLE, } _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkClimateEntityDescription( + ClimateEntityDescription, TPLinkModuleEntityDescription +): + """Base class for climate entity description.""" + + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: f"{legacy_device_id(device)}_{desc.key}" + ) + + +CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = ( + TPLinkClimateEntityDescription( + key="climate", + exists_fn=lambda dev, _: Module.Thermostat in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - # As there are no standalone thermostats, we just iterate over the children. - async_add_entities( - TPLinkClimateEntity(child, parent_coordinator, parent=device) - for child in device.children - if child.device_type is DeviceType.Thermostat - ) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkClimateEntity, + descriptions=CLIMATE_DESCRIPTIONS, + platform_domain=CLIMATE_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): +class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): """Representation of a TPLink thermostat.""" _attr_name = None @@ -66,78 +111,95 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_precision = PRECISION_TENTHS + entity_description: TPLinkClimateEntityDescription + # This disables the warning for async_turn_{on,off}, can be removed later. def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkClimateEntityDescription, *, parent: Device, ) -> None: """Initialize the climate entity.""" - self._state_feature = device.features["state"] - self._mode_feature = device.features["thermostat_mode"] - self._temp_feature = device.features["temperature"] - self._target_feature = device.features["target_temperature"] + super().__init__(device, coordinator, description, parent=parent) + self._thermostat_module = device.modules[Module.Thermostat] - self._attr_min_temp = self._target_feature.minimum_value - self._attr_max_temp = self._target_feature.maximum_value - self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)] + if target_feature := self._thermostat_module.get_feature("target_temperature"): + self._attr_min_temp = target_feature.minimum_value + self._attr_max_temp = target_feature.maximum_value + else: + _LOGGER.error( + "Unable to get min/max target temperature for %s, using defaults", + device.host, + ) - super().__init__(device, coordinator, parent=parent) + if temperature_feature := self._thermostat_module.get_feature("temperature"): + self._attr_temperature_unit = UNIT_MAPPING[ + cast(str, temperature_feature.unit) + ] + else: + _LOGGER.error( + "Unable to get correct temperature unit for %s, defaulting to celsius", + device.host, + ) + self._attr_temperature_unit = UnitOfTemperature.CELSIUS @async_refresh_after async def async_set_temperature(self, **kwargs: Any) -> None: """Set target temperature.""" - await self._target_feature.set_value(int(kwargs[ATTR_TEMPERATURE])) + await self._thermostat_module.set_target_temperature( + float(kwargs[ATTR_TEMPERATURE]) + ) @async_refresh_after async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode (heat/off).""" if hvac_mode is HVACMode.HEAT: - await self._state_feature.set_value(True) + await self._thermostat_module.set_state(True) elif hvac_mode is HVACMode.OFF: - await self._state_feature.set_value(False) + await self._thermostat_module.set_state(False) else: - raise ServiceValidationError(f"Tried to set unsupported mode: {hvac_mode}") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_mode", + translation_placeholders={ + "mode": hvac_mode, + }, + ) @async_refresh_after async def async_turn_on(self) -> None: """Turn heating on.""" - await self._state_feature.set_value(True) + await self._thermostat_module.set_state(True) @async_refresh_after async def async_turn_off(self) -> None: """Turn heating off.""" - await self._state_feature.set_value(False) + await self._thermostat_module.set_state(False) @callback def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" - self._attr_current_temperature = cast(float | None, self._temp_feature.value) - self._attr_target_temperature = cast(float | None, self._target_feature.value) + self._attr_current_temperature = self._thermostat_module.temperature + self._attr_target_temperature = self._thermostat_module.target_temperature self._attr_hvac_mode = ( - HVACMode.HEAT if self._state_feature.value else HVACMode.OFF + HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF ) if ( - self._mode_feature.value not in STATE_TO_ACTION + self._thermostat_module.mode not in STATE_TO_ACTION and self._attr_hvac_action is not HVACAction.OFF ): _LOGGER.warning( "Unknown thermostat state, defaulting to OFF: %s", - self._mode_feature.value, + self._thermostat_module.mode, ) self._attr_hvac_action = HVACAction.OFF return True - self._attr_hvac_action = STATE_TO_ACTION[ - cast(ThermostatState, self._mode_feature.value) - ] + self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode] return True - - def _get_unique_id(self) -> str: - """Return unique id.""" - return f"{self._device.device_id}_climate" diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 9bc278f8948..291a7e78c62 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -18,7 +18,7 @@ from kasa import ( ) import voluptuous as vol -from homeassistant.components import dhcp, ffmpeg, stream +from homeassistant.components import ffmpeg, stream from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -40,6 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from . import ( @@ -93,7 +94,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Device | None = None async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" return await self._async_handle_discovery( @@ -327,7 +328,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): host, port = self._async_get_host_port(host) - match_dict = {CONF_HOST: host} + match_dict: dict[str, Any] = {CONF_HOST: host} if port: self.port = port match_dict[CONF_PORT] = port diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 61c1bf1cb9b..2df7101791a 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -4,7 +4,9 @@ from __future__ import annotations from typing import Final -from homeassistant.const import Platform, UnitOfTemperature +from kasa.smart.modules.clean import AreaUnit + +from homeassistant.const import Platform, UnitOfArea, UnitOfTemperature DOMAIN = "tplink" @@ -41,9 +43,12 @@ PLATFORMS: Final = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.VACUUM, ] UNIT_MAPPING = { "celsius": UnitOfTemperature.CELSIUS, "fahrenheit": UnitOfTemperature.FAHRENHEIT, + AreaUnit.Sqm: UnitOfArea.SQUARE_METERS, + AreaUnit.Sqft: UnitOfArea.SQUARE_FEET, } diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 1c362d33746..1a7b40457f0 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -2,38 +2,64 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from kasa import AuthenticationError, Device, KasaException +from kasa import AuthenticationError, Credentials, Device, KasaException +from kasa.iot import IotStrip -from homeassistant import config_entries +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) + +@dataclass(slots=True) +class TPLinkData: + """Data for the tplink integration.""" + + parent_coordinator: TPLinkDataUpdateCoordinator + camera_credentials: Credentials | None + live_view: bool | None + + +type TPLinkConfigEntry = ConfigEntry[TPLinkData] + REQUEST_REFRESH_DELAY = 0.35 class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific TPLink device.""" - config_entry: config_entries.ConfigEntry + config_entry: TPLinkConfigEntry def __init__( self, hass: HomeAssistant, device: Device, update_interval: timedelta, + config_entry: TPLinkConfigEntry, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + + # The iot HS300 allows a limited number of concurrent requests and + # fetching the emeter information requires separate ones, so child + # coordinators are created below in get_child_coordinator. + self._update_children = not isinstance(device, IotStrip) + super().__init__( hass, _LOGGER, + config_entry=config_entry, name=device.host, update_interval=update_interval, # We don't want an immediate refresh since the device @@ -42,12 +68,77 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) + self._previous_child_device_ids = {child.device_id for child in device.children} + self.removed_child_device_ids: set[str] = set() + self._child_coordinators: dict[str, TPLinkDataUpdateCoordinator] = {} async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.update(update_children=False) + await self.device.update(update_children=self._update_children) except AuthenticationError as ex: - raise ConfigEntryAuthFailed from ex + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_authentication", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex except KasaException as ex: - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="device_error", + translation_placeholders={ + "func": "update", + "exc": str(ex), + }, + ) from ex + + await self._process_child_devices() + + async def _process_child_devices(self) -> None: + """Process child devices and remove stale devices.""" + current_child_device_ids = {child.device_id for child in self.device.children} + if ( + stale_device_ids := self._previous_child_device_ids + - current_child_device_ids + ): + device_registry = dr.async_get(self.hass) + for device_id in stale_device_ids: + device = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + child_coordinator = self._child_coordinators.pop(device_id, None) + if child_coordinator: + await child_coordinator.async_shutdown() + + self._previous_child_device_ids = current_child_device_ids + self.removed_child_device_ids = stale_device_ids + + def get_child_coordinator( + self, + child: Device, + platform_domain: str, + ) -> TPLinkDataUpdateCoordinator: + """Get separate child coordinator for a device or self if not needed.""" + # The iot HS300 allows a limited number of concurrent requests and fetching the + # emeter information requires separate ones so create child coordinators here. + # This does not happen for switches as the state is available on the + # parent device info. + if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN: + if not (child_coordinator := self._child_coordinators.get(child.device_id)): + # The child coordinators only update energy data so we can + # set a longer update interval to avoid flooding the device + child_coordinator = TPLinkDataUpdateCoordinator( + self.hass, child, timedelta(seconds=60), self.config_entry + ) + self._child_coordinators[child.device_id] = child_coordinator + return child_coordinator + + return self diff --git a/homeassistant/components/tplink/deprecate.py b/homeassistant/components/tplink/deprecate.py index 738f3d24c38..86d4f66cdc0 100644 --- a/homeassistant/components/tplink/deprecate.py +++ b/homeassistant/components/tplink/deprecate.py @@ -6,16 +6,20 @@ from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING +from kasa import Device + from homeassistant.components.automation import automations_with_entity +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from . import legacy_device_id from .const import DOMAIN if TYPE_CHECKING: - from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription + from .entity import CoordinatedTPLinkEntity, TPLinkEntityDescription @dataclass(slots=True) @@ -30,7 +34,7 @@ class DeprecatedInfo: def async_check_create_deprecated( hass: HomeAssistant, unique_id: str, - entity_description: TPLinkFeatureEntityDescription, + entity_description: TPLinkEntityDescription, ) -> bool: """Return true if the entity should be created based on the deprecated_info. @@ -58,13 +62,21 @@ def async_check_create_deprecated( return not entity_entry.disabled -def async_cleanup_deprecated( +def async_process_deprecated( hass: HomeAssistant, - platform: str, + platform_domain: str, entry_id: str, - entities: Sequence[CoordinatedTPLinkFeatureEntity], + entities: Sequence[CoordinatedTPLinkEntity], + device: Device, ) -> None: - """Remove disabled deprecated entities or create issues if necessary.""" + """Process deprecated entities for a device. + + Create issues for deprececated entities that appear in automations. + Delete entities that are no longer provided by the integration either + because they have been removed at the end of the deprecation period, or + they are disabled by the user so the async_check_create_deprecated + returned false. + """ ent_reg = er.async_get(hass) for entity in entities: if not (deprecated_info := entity.entity_description.deprecated_info): @@ -72,7 +84,7 @@ def async_cleanup_deprecated( assert entity.unique_id entity_id = ent_reg.async_get_entity_id( - platform, + platform_domain, DOMAIN, entity.unique_id, ) @@ -94,17 +106,27 @@ def async_cleanup_deprecated( translation_placeholders={ "entity": entity_id, "info": item, - "platform": platform, + "platform": platform_domain, "new_platform": deprecated_info.new_platform, }, ) + # The light platform does not currently support cleaning up disabled + # deprecated entities because it uses two entity classes so a completeness + # check is not possible. It also uses the mac address as device id in some + # instances instead of device_id. + if platform_domain == LIGHT_DOMAIN: + return + # Remove entities that are no longer provided and have been disabled. + device_id = legacy_device_id(device) + unique_ids = {entity.unique_id for entity in entities} for entity_entry in er.async_entries_for_config_entry(ent_reg, entry_id): if ( - entity_entry.domain == platform + entity_entry.domain == platform_domain and entity_entry.disabled + and entity_entry.unique_id.startswith(device_id) and entity_entry.unique_id not in unique_ids ): ent_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 935857e5db1..7c1e9e72b85 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable, Coroutine, Mapping +from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping from dataclasses import dataclass, replace import logging from typing import Any, Concatenate @@ -35,8 +35,12 @@ from .const import ( DOMAIN, PRIMARY_STATE_ID, ) -from .coordinator import TPLinkDataUpdateCoordinator -from .deprecate import DeprecatedInfo, async_check_create_deprecated +from .coordinator import TPLinkConfigEntry, TPLinkDataUpdateCoordinator +from .deprecate import ( + DeprecatedInfo, + async_check_create_deprecated, + async_process_deprecated, +) _LOGGER = logging.getLogger(__name__) @@ -55,6 +59,7 @@ DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = { DeviceType.Dimmer, DeviceType.Fan, DeviceType.Thermostat, + DeviceType.Vacuum, } # Primary features to always include even when the device type has its own platform @@ -85,7 +90,7 @@ LEGACY_KEY_MAPPING = { @dataclass(frozen=True, kw_only=True) -class TPLinkFeatureEntityDescription(EntityDescription): +class TPLinkEntityDescription(EntityDescription): """Base class for a TPLink feature based entity description.""" deprecated_info: DeprecatedInfo | None = None @@ -93,11 +98,21 @@ class TPLinkFeatureEntityDescription(EntityDescription): @dataclass(frozen=True, kw_only=True) -class TPLinkModuleEntityDescription(EntityDescription): +class TPLinkFeatureEntityDescription(TPLinkEntityDescription): + """Base class for a TPLink feature based entity description.""" + + +@dataclass(frozen=True, kw_only=True) +class TPLinkModuleEntityDescription(TPLinkEntityDescription): """Base class for a TPLink module based entity description.""" - deprecated_info: DeprecatedInfo | None = None - available_fn: Callable[[Device], bool] = lambda _: True + exists_fn: Callable[[Device, TPLinkConfigEntry], bool] + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: f"{legacy_device_id(device)}-{desc.key}" + ) + entity_name_fn: ( + Callable[[Device, TPLinkModuleEntityDescription], str | None] | None + ) = None def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -147,21 +162,29 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB _attr_has_entity_name = True _device: Device + entity_description: TPLinkEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkEntityDescription, *, feature: Feature | None = None, parent: Device | None = None, ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self.entity_description = description self._device: Device = device + self._parent = parent self._feature = feature registry_device = device device_name = get_device_name(device, parent=parent) + translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None + if parent and parent.device_type is not Device.Type.Hub: if not feature or feature.id == PRIMARY_STATE_ID: # Entity will be added to parent if not a hub and no feature @@ -169,6 +192,9 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB # is the primary state registry_device = parent device_name = get_device_name(registry_device) + if not device_name: + translation_key = "unnamed_device" + translation_placeholders = {"model": parent.model} else: # Prefix the device name with the parent name unless it is a # hub attached device. Sensible default for child devices like @@ -177,17 +203,36 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB # Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan # and Dimmer Switch for both so should be distinguished by the # parent name. - device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}" + parent_device_name = get_device_name(parent) + child_device_name = get_device_name(device, parent=parent) + if parent_device_name: + device_name = f"{parent_device_name} {child_device_name}" + else: + device_name = None + translation_key = "unnamed_device" + translation_placeholders = { + "model": f"{parent.model} {child_device_name}" + } + + if device_name is None and not translation_key: + translation_key = "unnamed_device" + translation_placeholders = {"model": device.model} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(registry_device.device_id))}, manufacturer="TP-Link", model=registry_device.model, name=device_name, + translation_key=translation_key, + translation_placeholders=translation_placeholders, sw_version=registry_device.hw_info["sw_ver"], hw_version=registry_device.hw_info["hw_ver"], ) + # child device entities will link via_device unless they were created + # above on the parent. Otherwise the mac connections is set which or + # for wall switches like the ks240 will mean the child and parent devices + # are treated as one device. if ( parent is not None and parent != registry_device @@ -201,11 +246,15 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], AB self._attr_unique_id = self._get_unique_id() - self._async_call_update_attrs() - def _get_unique_id(self) -> str: """Return unique ID for the entity.""" - return legacy_device_id(self._device) + raise NotImplementedError + + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._async_call_update_attrs() @abstractmethod @callback @@ -255,14 +304,19 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkFeatureEntityDescription, *, feature: Feature, - description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: """Initialize the entity.""" - self.entity_description = description - super().__init__(device, coordinator, parent=parent, feature=feature) + super().__init__( + device, coordinator, description, parent=parent, feature=feature + ) + + # Update the feature attributes so the registered entity contains + # values like unit_of_measurement and suggested_display_precision + self._async_call_update_attrs() def _get_unique_id(self) -> str: """Return unique ID for the entity.""" @@ -320,6 +374,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): if descriptions and (desc := descriptions.get(feature.id)): translation_key: str | None = feature.id + # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name # > device_class (if base platform supports). @@ -363,6 +418,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], + platform_domain: str, parent: Device | None = None, ) -> list[_E]: """Return a list of entities to add. @@ -397,6 +453,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): desc, ) ] + async_process_deprecated( + hass, platform_domain, coordinator.config_entry.entry_id, entities, device + ) return entities @classmethod @@ -412,7 +471,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): feature_type: Feature.Type, entity_class: type[_E], descriptions: Mapping[str, _D], - child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None, + platform_domain: str, + known_child_device_ids: set[str], + first_check: bool, ) -> list[_E]: """Create entities for device and its children. @@ -420,36 +481,242 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): """ entities: list[_E] = [] # Add parent entities before children so via_device id works. - entities.extend( - cls._entities_for_device( + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + feature_type=feature_type, + entity_class=entity_class, + descriptions=descriptions, + platform_domain=platform_domain, + ) + ) + + children = _get_new_children( + device, coordinator, known_child_device_ids, entity_class.__name__ + ) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + + for child in children: + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) + + child_entities = cls._entities_for_device( hass, - device, - coordinator=coordinator, + child, + coordinator=child_coordinator, feature_type=feature_type, entity_class=entity_class, descriptions=descriptions, + platform_domain=platform_domain, + parent=device, ) - ) - if device.children: - _LOGGER.debug("Initializing device with %s children", len(device.children)) - for idx, child in enumerate(device.children): - # HS300 does not like too many concurrent requests and its - # emeter data requires a request for each socket, so we receive - # separate coordinators. - if child_coordinators: - child_coordinator = child_coordinators[idx] - else: - child_coordinator = coordinator - entities.extend( - cls._entities_for_device( - hass, - child, - coordinator=child_coordinator, - feature_type=feature_type, - entity_class=entity_class, - descriptions=descriptions, - parent=device, - ) - ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) return entities + + +class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): + """Common base class for all coordinated tplink module based entities.""" + + entity_description: TPLinkModuleEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkModuleEntityDescription, + *, + parent: Device | None = None, + ) -> None: + """Initialize the entity.""" + super().__init__(device, coordinator, description, parent=parent) + + # Module based entities will usually be 1 per device so they will use + # the device name. If there are multiple module entities based entities + # the description should have a translation key. + # HA logic is to name entities based on the following logic: + # _attr_name > translation.name > description.name + if entity_name_fn := description.entity_name_fn: + self._attr_name = entity_name_fn(device, description) + elif not description.translation_key: + if parent is None or parent.device_type is Device.Type.Hub: + self._attr_name = None + else: + self._attr_name = get_device_name(device) + + def _get_unique_id(self) -> str: + """Return unique ID for the entity.""" + desc = self.entity_description + return desc.unique_id_fn(self._device, desc) + + @classmethod + def _entities_for_device[ + _E: CoordinatedTPLinkModuleEntity, + _D: TPLinkModuleEntityDescription, + ]( + cls, + hass: HomeAssistant, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + entity_class: type[_E], + descriptions: Iterable[_D], + platform_domain: str, + parent: Device | None = None, + ) -> list[_E]: + """Return a list of entities to add.""" + entities: list[_E] = [ + entity_class( + device, + coordinator, + description=description, + parent=parent, + ) + for description in descriptions + if description.exists_fn(device, coordinator.config_entry) + and async_check_create_deprecated( + hass, + description.unique_id_fn(device, description), + description, + ) + ] + async_process_deprecated( + hass, platform_domain, coordinator.config_entry.entry_id, entities, device + ) + return entities + + @classmethod + def entities_for_device_and_its_children[ + _E: CoordinatedTPLinkModuleEntity, + _D: TPLinkModuleEntityDescription, + ]( + cls, + hass: HomeAssistant, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + entity_class: type[_E], + descriptions: Iterable[_D], + platform_domain: str, + known_child_device_ids: set[str], + first_check: bool, + ) -> list[_E]: + """Create entities for device and its children. + + This is a helper that calls *_entities_for_device* for the device and its children. + """ + entities: list[_E] = [] + + # Add parent entities before children so via_device id works. + # Only add the parent entities the first time + if first_check: + entities.extend( + cls._entities_for_device( + hass, + device, + coordinator=coordinator, + entity_class=entity_class, + descriptions=descriptions, + platform_domain=platform_domain, + ) + ) + has_parent_entities = bool(entities) + + children = _get_new_children( + device, coordinator, known_child_device_ids, entity_class.__name__ + ) + + if children: + _LOGGER.debug( + "Getting %s entities for %s child devices on device %s", + entity_class.__name__, + len(children), + device.host, + ) + for child in children: + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) + + child_entities: list[_E] = cls._entities_for_device( + hass, + child, + coordinator=child_coordinator, + entity_class=entity_class, + descriptions=descriptions, + platform_domain=platform_domain, + parent=device, + ) + _LOGGER.debug( + "Device %s, found %s child %s entities for child id %s", + device.host, + len(entities), + entity_class.__name__, + child.device_id, + ) + entities.extend(child_entities) + + if first_check and entities and not has_parent_entities: + # Get or create the parent device for via_device. + # This is a timing factor in case this platform is loaded before + # other platforms that will have entities on the parent. Eventually + # those other platforms will update the parent with full DeviceInfo + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=coordinator.config_entry.entry_id, + identifiers={(DOMAIN, device.device_id)}, + ) + return entities + + +def _get_new_children( + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + known_child_device_ids: set[str], + entity_class_name: str, +) -> list[Device]: + """Get a list of children to check for entity creation.""" + # Remove any device ids removed via the coordinator so they can be re-added + for removed_child_id in coordinator.removed_child_device_ids: + _LOGGER.debug( + "Removing %s from known %s child ids for device %s" + "as it has been removed by the coordinator", + removed_child_id, + entity_class_name, + device.host, + ) + known_child_device_ids.discard(removed_child_id) + + current_child_devices = {child.device_id: child for child in device.children} + current_child_device_ids = set(current_child_devices.keys()) + new_child_device_ids = current_child_device_ids - known_child_device_ids + children = [] + + if new_child_device_ids: + children = [ + child + for child_id, child in current_child_devices.items() + if child_id in new_child_device_ids + ] + known_child_device_ids.update(new_child_device_ids) + return children + return [] diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index a1e62e4ed69..88396742b36 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -1,61 +1,96 @@ """Support for TPLink Fan devices.""" +from collections.abc import Callable +from dataclasses import dataclass import logging import math from typing import Any from kasa import Device, Module -from kasa.interfaces import Fan as FanInterface -from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntity, + FanEntityDescription, + FanEntityFeature, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, ) from homeassistant.util.scaling import int_states_in_range -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkFanEntityDescription(FanEntityDescription, TPLinkModuleEntityDescription): + """Base class for fan entity description.""" + + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: legacy_device_id(device) + if desc.key == "fan" + else f"{legacy_device_id(device)}-{desc.key}" + ) + + +FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = ( + TPLinkFanEntityDescription( + key="fan", + exists_fn=lambda dev, _: Module.Fan in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fans.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - entities: list[CoordinatedTPLinkEntity] = [] - if Module.Fan in device.modules: - entities.append( - TPLinkFanEntity( - device, parent_coordinator, fan_module=device.modules[Module.Fan] - ) + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkFanEntity, + descriptions=FAN_DESCRIPTIONS, + platform_domain=FAN_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, ) - entities.extend( - TPLinkFanEntity( - child, - parent_coordinator, - fan_module=child.modules[Module.Fan], - parent=device, - ) - for child in device.children - if Module.Fan in child.modules - ) - async_add_entities(entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) SPEED_RANGE = (1, 4) # off is not included -class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): +class TPLinkFanEntity(CoordinatedTPLinkModuleEntity, FanEntity): """Representation of a fan for a TPLink Fan device.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) @@ -65,19 +100,19 @@ class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): | FanEntityFeature.TURN_ON ) + entity_description: TPLinkFanEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, - fan_module: FanInterface, + description: TPLinkFanEntityDescription, + *, parent: Device | None = None, ) -> None: """Initialize the fan.""" - self.fan_module = fan_module - # If _attr_name is None the entity name will be the device name - self._attr_name = None if parent is None else device.alias - - super().__init__(device, coordinator, parent=parent) + super().__init__(device, coordinator, description, parent=parent) + self.fan_module = device.modules[Module.Fan] @async_refresh_after async def async_turn_on( diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 9cc0326b59f..73bb40a8386 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -32,7 +32,20 @@ }, "tilt_down": { "default": "mdi:chevron-down" - } + }, + "main_brush_reset": { + "default": "mdi:brush" + }, + "side_brush_reset": { + "default": "mdi:brush" + }, + "sensor_reset": { + "default": "mdi:eye-outline" + }, + "filter_reset": { + "default": "mdi:air-filter" + }, + "charging_contacts_reset": {} }, "select": { "light_preset": { @@ -113,6 +126,9 @@ "state": { "on": "mdi:baby-face" } + }, + "carpet_boost": { + "default": "mdi:rug" } }, "sensor": { @@ -125,17 +141,40 @@ "signal_level": { "default": "mdi:signal" }, - "current_firmware_version": { - "default": "mdi:information" - }, - "available_firmware_version": { - "default": "mdi:information-outline" - }, "alarm_source": { "default": "mdi:bell" }, "water_alert_timestamp": { "default": "mdi:clock-alert-outline" + }, + "main_brush_remaining": { + "default": "mdi:brush" + }, + "main_brush_used": { + "default": "mdi:brush" + }, + "side_brush_remaining": { + "default": "mdi:brush" + }, + "side_brush_used": { + "default": "mdi:brush" + }, + "filter_remaining": { + "default": "mdi:air-filter" + }, + "filter_used": { + "default": "mdi:air-filter" + }, + "sensor_remaining": { + "default": "mdi:eye-outline" + }, + "sensor_used": { + "default": "mdi:eye-outline" + }, + "charging_contacts_remaining": {}, + "charging_contacts_used": {}, + "vacuum_error": { + "default": "mdi:alert-circle" } }, "number": { @@ -151,14 +190,14 @@ "temperature_offset": { "default": "mdi:contrast" }, - "target_temperature": { - "default": "mdi:thermometer" - }, "pan_step": { "default": "mdi:unfold-more-vertical" }, "tilt_step": { "default": "mdi:unfold-more-horizontal" + }, + "clean_count": { + "default": "mdi:counter" } } }, diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 91e2a784af2..b3cee1d3baf 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -3,11 +3,12 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass import logging from typing import Any -from kasa import Device, DeviceType, LightState, Module -from kasa.interfaces import Light, LightEffect +from kasa import Device, DeviceType, KasaException, LightState, Module +from kasa.interfaces import LightEffect from kasa.iot import IotDevice import voluptuous as vol @@ -17,21 +18,32 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) @@ -130,75 +142,122 @@ def _async_build_base_effect( } +def _get_backwards_compatible_light_unique_id( + device: Device, entity_description: TPLinkModuleEntityDescription +) -> str: + """Return unique ID for the entity.""" + # For historical reasons the light platform uses the mac address as + # the unique id whereas all other platforms use device_id. + + # For backwards compat with pyHS100 + if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): + # Dimmers used to use the switch format since + # pyHS100 treated them as SmartPlug but the old code + # created them as lights + # https://github.com/home-assistant/core/blob/2021.9.7/ \ + # homeassistant/components/tplink/common.py#L86 + return legacy_device_id(device) + + # Newer devices can have child lights. While there isn't currently + # an example of a device with more than one light we use the device_id + # for consistency and future proofing + if device.parent or device.children: + return legacy_device_id(device) + + return device.mac.replace(":", "").upper() + + +@dataclass(frozen=True, kw_only=True) +class TPLinkLightEntityDescription( + LightEntityDescription, TPLinkModuleEntityDescription +): + """Base class for tplink light entity description.""" + + unique_id_fn = _get_backwards_compatible_light_unique_id + + +LIGHT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( + TPLinkLightEntityDescription( + key="light", + exists_fn=lambda dev, _: Module.Light in dev.modules + and Module.LightEffect not in dev.modules, + ), +) + +LIGHT_EFFECT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( + TPLinkLightEntityDescription( + key="light_effect", + exists_fn=lambda dev, _: Module.Light in dev.modules + and Module.LightEffect in dev.modules, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up lights.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - entities: list[TPLinkLightEntity | TPLinkLightEffectEntity] = [] - if effect_module := device.modules.get(Module.LightEffect): - entities.append( - TPLinkLightEffectEntity( - device, - parent_coordinator, - light_module=device.modules[Module.Light], - effect_module=effect_module, + + known_child_device_ids_light: set[str] = set() + known_child_device_ids_light_effect: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkLightEntity, + descriptions=LIGHT_DESCRIPTIONS, + platform_domain=LIGHT_DOMAIN, + known_child_device_ids=known_child_device_ids_light, + first_check=first_check, + ) + entities.extend( + CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkLightEffectEntity, + descriptions=LIGHT_EFFECT_DESCRIPTIONS, + platform_domain=LIGHT_DOMAIN, + known_child_device_ids=known_child_device_ids_light_effect, + first_check=first_check, ) ) - if effect_module.has_custom_effects: - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_RANDOM_EFFECT, - RANDOM_EFFECT_DICT, - "async_set_random_effect", - ) - platform.async_register_entity_service( - SERVICE_SEQUENCE_EFFECT, - SEQUENCE_EFFECT_DICT, - "async_set_sequence_effect", - ) - elif Module.Light in device.modules: - entities.append( - TPLinkLightEntity( - device, parent_coordinator, light_module=device.modules[Module.Light] - ) - ) - entities.extend( - TPLinkLightEntity( - child, - parent_coordinator, - light_module=child.modules[Module.Light], - parent=device, - ) - for child in device.children - if Module.Light in child.modules - ) - async_add_entities(entities) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): +class TPLinkLightEntity(CoordinatedTPLinkModuleEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" _attr_supported_features = LightEntityFeature.TRANSITION _fixed_color_mode: ColorMode | None = None + entity_description: TPLinkLightEntityDescription + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkLightEntityDescription, *, - light_module: Light, parent: Device | None = None, ) -> None: """Initialize the light.""" - self._parent = parent + super().__init__(device, coordinator, description, parent=parent) + + light_module = device.modules[Module.Light] self._light_module = light_module - # If _attr_name is None the entity name will be the device name - self._attr_name = None if parent is None else device.alias modes: set[ColorMode] = {ColorMode.ONOFF} if color_temp_feat := light_module.get_feature("color_temp"): modes.add(ColorMode.COLOR_TEMP) @@ -213,31 +272,6 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): # If the light supports only a single color mode, set it now self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) - super().__init__(device, coordinator, parent=parent) - - def _get_unique_id(self) -> str: - """Return unique ID for the entity.""" - # For historical reasons the light platform uses the mac address as - # the unique id whereas all other platforms use device_id. - device = self._device - - # For backwards compat with pyHS100 - if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): - # Dimmers used to use the switch format since - # pyHS100 treated them as SmartPlug but the old code - # created them as lights - # https://github.com/home-assistant/core/blob/2021.9.7/ \ - # homeassistant/components/tplink/common.py#L86 - return legacy_device_id(device) - - # Newer devices can have child lights. While there isn't currently - # an example of a device with more than one light we use the device_id - # for consistency and future proofing - if self._parent or device.children: - return legacy_device_id(device) - - return device.mac.replace(":", "").upper() - @callback def _async_extract_brightness_transition( self, **kwargs: Any @@ -355,19 +389,39 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): class TPLinkLightEffectEntity(TPLinkLightEntity): """Representation of a TPLink Smart Light Strip.""" + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkLightEntityDescription, *, - light_module: Light, - effect_module: LightEffect, + parent: Device | None = None, ) -> None: """Initialize the light strip.""" - self._effect_module = effect_module - super().__init__(device, coordinator, light_module=light_module) + super().__init__(device, coordinator, description, parent=parent) - _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + self._effect_module = device.modules[Module.LightEffect] + + async def async_added_to_hass(self) -> None: + """Call update attributes after the device is added to the platform.""" + await super().async_added_to_hass() + + self._register_effects_services() + + def _register_effects_services(self) -> None: + if self._effect_module.has_custom_effects: + self.platform.async_register_entity_service( + SERVICE_RANDOM_EFFECT, + RANDOM_EFFECT_DICT, + "async_set_random_effect", + ) + self.platform.async_register_entity_service( + SERVICE_SEQUENCE_EFFECT, + SEQUENCE_EFFECT_DICT, + "async_set_sequence_effect", + ) @callback def _async_update_attrs(self) -> bool: @@ -458,7 +512,17 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): if transition_range: effect["transition_range"] = transition_range effect["transition"] = 0 - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex async def async_set_sequence_effect( self, @@ -480,4 +544,14 @@ class TPLinkLightEffectEntity(TPLinkLightEntity): "spread": spread, "direction": direction, } - await self._effect_module.set_custom_effect(effect) + try: + await self._effect_module.set_custom_effect(effect) + except KasaException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_custom_effect", + translation_placeholders={ + "effect": str(effect), + "exc": str(ex), + }, + ) from ex diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a975e675ceb..cdd6ab57c6a 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,6 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.9.1"] + "quality_scale": "platinum", + "requirements": ["python-kasa[speedups]==0.10.2"] } diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py deleted file mode 100644 index 389260a388b..00000000000 --- a/homeassistant/components/tplink/models.py +++ /dev/null @@ -1,19 +0,0 @@ -"""The tplink integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from kasa import Credentials - -from .coordinator import TPLinkDataUpdateCoordinator - - -@dataclass(slots=True) -class TPLinkData: - """Data for the tplink integration.""" - - parent_coordinator: TPLinkDataUpdateCoordinator - children_coordinators: list[TPLinkDataUpdateCoordinator] - camera_credentials: Credentials | None - live_view: bool | None diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 3f7fa9c3e0f..252c4888d26 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -9,12 +9,13 @@ from typing import Final, cast from kasa import Device, Feature from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, NumberMode, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import ( @@ -31,7 +32,12 @@ _LOGGER = logging.getLogger(__name__) class TPLinkNumberEntityDescription( NumberEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based number entity description.""" + + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 NUMBER_DESCRIPTIONS: Final = ( @@ -59,6 +65,14 @@ NUMBER_DESCRIPTIONS: Final = ( key="tilt_step", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="power_protection_threshold", + mode=NumberMode.SLIDER, + ), + TPLinkNumberEntityDescription( + key="clean_count", + mode=NumberMode.SLIDER, + ), ) NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} @@ -67,28 +81,36 @@ NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up number entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Number, - entity_class=TPLinkNumberEntity, - descriptions=NUMBER_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) + known_child_device_ids: set[str] = set() + first_check = True - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Number, + entity_class=TPLinkNumberEntity, + descriptions=NUMBER_DESCRIPTIONS_MAP, + platform_domain=NUMBER_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): - """Representation of a feature-based TPLink sensor.""" + """Representation of a feature-based TPLink number entity.""" entity_description: TPLinkNumberEntityDescription @@ -101,7 +123,7 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): description: TPLinkFeatureEntityDescription, parent: Device | None = None, ) -> None: - """Initialize the a switch.""" + """Initialize the number entity.""" super().__init__( device, coordinator, feature=feature, description=description, parent=parent ) diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml new file mode 100644 index 00000000000..f120945771c --- /dev/null +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: The integration does not use events. + dependency-transparency: done + action-setup: + status: exempt + comment: The integration only uses platform services. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: done + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters. + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: done + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: done + repair-issues: done + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 5dd8e54fca8..72042f571e6 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -7,9 +7,13 @@ from typing import Final, cast from kasa import Device, Feature -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import ( @@ -24,9 +28,13 @@ from .entity import ( class TPLinkSelectEntityDescription( SelectEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based select entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + SELECT_DESCRIPTIONS: Final = [ TPLinkSelectEntityDescription( key="light_preset", @@ -45,24 +53,32 @@ SELECT_DESCRIPTIONS_MAP = {desc.key: desc for desc in SELECT_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up sensors.""" + """Set up select entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Choice, - entity_class=TPLinkSelectEntity, - descriptions=SELECT_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Choice, + entity_class=TPLinkSelectEntity, + descriptions=SELECT_DESCRIPTIONS_MAP, + platform_domain=SELECT_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index da4bf72122d..cc35b1fd142 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -2,10 +2,13 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from operator import methodcaller +from typing import TYPE_CHECKING, Any, cast from kasa import Feature +from kasa.smart.modules.clean import ErrorCode as VacuumError from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -14,12 +17,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .const import UNIT_MAPPING -from .deprecate import async_cleanup_deprecated from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -29,6 +32,14 @@ class TPLinkSensorEntityDescription( ): """Base class for a TPLink feature based sensor entity description.""" + #: Optional callable to convert the value + convert_fn: Callable[[Any], Any] | None = None + + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_TOTAL_SECONDS_METHOD_CALLER = methodcaller("total_seconds") SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( @@ -113,11 +124,145 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + # Vacuum cleaning records TPLinkSensorEntityDescription( - key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key="clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="clean_area", + device_class=SensorDeviceClass.AREA, state_class=SensorStateClass.MEASUREMENT, ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="clean_progress", + state_class=SensorStateClass.MEASUREMENT, + ), + TPLinkSensorEntityDescription( + key="last_clean_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="last_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="last_clean_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="total_clean_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="total_clean_area", + device_class=SensorDeviceClass.AREA, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TPLinkSensorEntityDescription( + key="total_clean_count", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="main_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="main_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="side_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="side_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="filter_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="filter_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="sensor_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="sensor_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="charging_contacts_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + entity_registry_enabled_default=False, + key="charging_contacts_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="vacuum_error", + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in VacuumError._member_names_], + convert_fn=lambda x: x.name.lower(), + ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} @@ -126,25 +271,32 @@ SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - children_coordinators = data.children_coordinators device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Type.Sensor, - entity_class=TPLinkSensorEntity, - descriptions=SENSOR_DESCRIPTIONS_MAP, - child_coordinators=children_coordinators, - ) - async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) - async_add_entities(entities) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Sensor, + entity_class=TPLinkSensorEntity, + descriptions=SENSOR_DESCRIPTIONS_MAP, + platform_domain=SENSOR_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): @@ -161,6 +313,9 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): # We probably do not need this, when we are rounding already? self._attr_suggested_display_precision = self._feature.precision_hint + if self.entity_description.convert_fn: + value = self.entity_description.convert_fn(value) + if TYPE_CHECKING: # pylint: disable-next=import-outside-toplevel from datetime import date, datetime diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 141ea696358..65cb722052f 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -1,54 +1,152 @@ -"""Support for TPLink hub alarm.""" +"""Support for TPLink siren entity.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +import math +from typing import TYPE_CHECKING, Any, cast from kasa import Device, Module -from kasa.smart.modules.alarm import Alarm -from homeassistant.components.siren import SirenEntity, SirenEntityFeature +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, + DOMAIN as SIREN_DOMAIN, + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, + SirenTurnOnServiceParameters, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TPLinkConfigEntry +from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TPLinkSirenEntityDescription( + SirenEntityDescription, TPLinkModuleEntityDescription +): + """Base class for siren entity description.""" + + unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( + lambda device, desc: legacy_device_id(device) + if desc.key == "siren" + else f"{legacy_device_id(device)}-{desc.key}" + ) + + +SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = ( + TPLinkSirenEntityDescription( + key="siren", + exists_fn=lambda dev, _: Module.Alarm in dev.modules, + ), +) async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up siren entities.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - if Module.Alarm in device.modules: - async_add_entities([TPLinkSirenEntity(device, parent_coordinator)]) + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkSirenEntity, + descriptions=SIREN_DESCRIPTIONS, + platform_domain=SIREN_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) -class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): - """Representation of a tplink hub alarm.""" +class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): + """Representation of a tplink siren entity.""" _attr_name = None - _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + _attr_supported_features = ( + SirenEntityFeature.TURN_OFF + | SirenEntityFeature.TURN_ON + | SirenEntityFeature.TONES + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) + + entity_description: TPLinkSirenEntityDescription def __init__( self, device: Device, coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkSirenEntityDescription, + *, + parent: Device | None = None, ) -> None: """Initialize the siren entity.""" - self._alarm_module: Alarm = device.modules[Module.Alarm] - super().__init__(device, coordinator) + super().__init__(device, coordinator, description, parent=parent) + self._alarm_module = device.modules[Module.Alarm] + + alarm_vol_feat = self._alarm_module.get_feature("alarm_volume") + alarm_duration_feat = self._alarm_module.get_feature("alarm_duration") + if TYPE_CHECKING: + assert alarm_vol_feat + assert alarm_duration_feat + self._alarm_volume_max = alarm_vol_feat.maximum_value + self._alarm_duration_max = alarm_duration_feat.maximum_value @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on.""" - await self._alarm_module.play() + turn_on_params = cast(SirenTurnOnServiceParameters, kwargs) + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + # service parameter is a % so we round up to the nearest int + volume = math.ceil(volume * self._alarm_volume_max) + + if (duration := kwargs.get(ATTR_DURATION)) is not None: + if duration < 1 or duration > self._alarm_duration_max: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_alarm_duration", + translation_placeholders={ + "duration": str(duration), + "duration_max": str(self._alarm_duration_max), + }, + ) + + await self._alarm_module.play( + duration=turn_on_params.get(ATTR_DURATION), + volume=volume, + sound=kwargs.get(ATTR_TONE), + ) @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: @@ -59,4 +157,8 @@ class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = self._alarm_module.active + # alarm_sounds returns list[str], so we need to widen the type + self._attr_available_tones = cast( + list[str | int], self._alarm_module.alarm_sounds + ) return True diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 185705ff163..ded4806a726 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -109,26 +109,12 @@ "overheated": { "name": "Overheated" }, - "battery_low": { - "name": "Battery low" + "overloaded": { + "name": "Overloaded" }, "cloud_connection": { "name": "Cloud connection" }, - "update_available": { - "name": "[%key:component::binary_sensor::entity_component::update::name%]", - "state": { - "off": "[%key:component::binary_sensor::entity_component::update::state::off%]", - "on": "[%key:component::binary_sensor::entity_component::update::state::on%]" - } - }, - "is_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]", - "state": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - } - }, "water_alert": { "name": "[%key:component::binary_sensor::entity_component::moisture::name%]", "state": { @@ -155,6 +141,27 @@ }, "tilt_down": { "name": "Tilt down" + }, + "pair": { + "name": "Pair new device" + }, + "unpair": { + "name": "Unpair device" + }, + "main_brush_reset": { + "name": "Reset main brush consumable" + }, + "side_brush_reset": { + "name": "Reset side brush consumable" + }, + "sensor_reset": { + "name": "Reset sensor consumable" + }, + "filter_reset": { + "name": "Reset filter consumable" + }, + "charging_contacts_reset": { + "name": "Reset charging contacts consumable" } }, "camera": { @@ -195,27 +202,6 @@ "signal_level": { "name": "Signal level" }, - "current_firmware_version": { - "name": "Current firmware version" - }, - "available_firmware_version": { - "name": "Available firmware version" - }, - "battery_level": { - "name": "Battery level" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, - "current": { - "name": "[%key:component::sensor::entity_component::current::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "device_time": { "name": "Device time" }, @@ -231,8 +217,79 @@ "alarm_source": { "name": "Alarm source" }, - "rssi": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + "clean_area": { + "name": "Cleaning area" + }, + "clean_time": { + "name": "Cleaning time" + }, + "clean_progress": { + "name": "Cleaning progress" + }, + "total_clean_area": { + "name": "Total cleaning area" + }, + "total_clean_time": { + "name": "Total cleaning time" + }, + "total_clean_count": { + "name": "Total cleaning count" + }, + "last_clean_area": { + "name": "Last cleaned area" + }, + "last_clean_time": { + "name": "Last cleaned time" + }, + "last_clean_timestamp": { + "name": "Last clean start" + }, + "main_brush_remaining": { + "name": "Main brush remaining" + }, + "main_brush_used": { + "name": "Main brush used" + }, + "side_brush_remaining": { + "name": "Side brush remaining" + }, + "side_brush_used": { + "name": "Side brush used" + }, + "filter_remaining": { + "name": "Filter remaining" + }, + "filter_used": { + "name": "Filter used" + }, + "sensor_remaining": { + "name": "Sensor remaining" + }, + "sensor_used": { + "name": "Sensor used" + }, + "charging_contacts_remaining": { + "name": "Charging contacts remaining" + }, + "charging_contacts_used": { + "name": "Charging contacts used" + }, + "vacuum_error": { + "name": "Error", + "state": { + "ok": "No error", + "sidebrushstuck": "Side brush stuck", + "mainbrushstuck": "Main brush stuck", + "wheelblocked": "Wheel blocked", + "trapped": "Unable to move", + "trappedcliff": "Unable to move (cliff sensor)", + "dustbinremoved": "Missing dust bin", + "unabletomove": "Unable to move", + "lidarblocked": "Lidar blocked", + "unabletofinddock": "Unable to find dock", + "batterylow": "Low on battery", + "unknowninternal": "Unknown error, report to upstream" + } } }, "switch": { @@ -268,6 +325,9 @@ }, "baby_cry_detection": { "name": "Baby cry detection" + }, + "carpet_boost": { + "name": "Carpet boost" } }, "number": { @@ -283,12 +343,38 @@ "temperature_offset": { "name": "Temperature offset" }, + "power_protection_threshold": { + "name": "Power protection" + }, "pan_step": { "name": "Pan degrees" }, "tilt_step": { "name": "Tilt degrees" + }, + "clean_count": { + "name": "Clean count" } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "quiet": "Quiet", + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + "ultra": "Ultra" + } + } + } + } + } + }, + "device": { + "unnamed_device": { + "name": "Unnamed {model}" } }, "services": { @@ -394,6 +480,18 @@ }, "device_authentication": { "message": "Device authentication error {func}: {exc}" + }, + "set_custom_effect": { + "message": "Error trying to set custom effect {effect}: {exc}" + }, + "unexpected_device": { + "message": "Unexpected device found at {host}; expected {expected}, found {found}" + }, + "unsupported_mode": { + "message": "Tried to set unsupported mode: {mode}" + }, + "invalid_alarm_duration": { + "message": "Invalid duration {duration} available: 1-{duration_max}s" } }, "issues": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 7a879fb3c70..3cb20d63cd7 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -8,9 +8,13 @@ from typing import Any, cast from kasa import Feature -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import ( @@ -26,9 +30,13 @@ _LOGGER = logging.getLogger(__name__) class TPLinkSwitchEntityDescription( SwitchEntityDescription, TPLinkFeatureEntityDescription ): - """Base class for a TPLink feature based sensor entity description.""" + """Base class for a TPLink feature based switch entity description.""" +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="state", @@ -66,6 +74,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="baby_cry_detection", ), + TPLinkSwitchEntityDescription( + key="carpet_boost", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} @@ -74,23 +85,32 @@ SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device + known_child_device_ids: set[str] = set() + first_check = True - entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( - hass=hass, - device=device, - coordinator=parent_coordinator, - feature_type=Feature.Switch, - entity_class=TPLinkSwitch, - descriptions=SWITCH_DESCRIPTIONS_MAP, - ) + def _check_device() -> None: + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Switch, + entity_class=TPLinkSwitch, + descriptions=SWITCH_DESCRIPTIONS_MAP, + platform_domain=SWITCH_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) - async_add_entities(entities) + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py new file mode 100644 index 00000000000..e948e778be4 --- /dev/null +++ b/homeassistant/components/tplink/vacuum.py @@ -0,0 +1,162 @@ +"""Support for TPLink vacuum.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from kasa import Device, Module +from kasa.smart.modules.clean import Clean, Status + +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + StateVacuumEntity, + StateVacuumEntityDescription, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TPLinkConfigEntry +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + +# Upstream state to VacuumActivity +STATUS_TO_ACTIVITY = { + Status.Idle: VacuumActivity.IDLE, + Status.Cleaning: VacuumActivity.CLEANING, + Status.GoingHome: VacuumActivity.RETURNING, + Status.Charging: VacuumActivity.DOCKED, + Status.Charged: VacuumActivity.DOCKED, + Status.Undocked: VacuumActivity.IDLE, + Status.Paused: VacuumActivity.PAUSED, + Status.Error: VacuumActivity.ERROR, +} + + +@dataclass(frozen=True, kw_only=True) +class TPLinkVacuumEntityDescription( + StateVacuumEntityDescription, TPLinkModuleEntityDescription +): + """Base class for vacuum entity description.""" + + +VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( + TPLinkVacuumEntityDescription( + key="vacuum", + translation_key="vacuum", + exists_fn=lambda dev, _: Module.Clean in dev.modules, + entity_name_fn=lambda _, __: None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up vacuum entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkVacuumEntity, + descriptions=VACUUM_DESCRIPTIONS, + platform_domain=VACUUM_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) + + +class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): + """Representation of a tplink vacuum.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.START + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + ) + + entity_description: TPLinkVacuumEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkVacuumEntityDescription, + *, + parent: Device, + ) -> None: + """Initialize the vacuum entity.""" + super().__init__(device, coordinator, description, parent=parent) + self._vacuum_module: Clean = device.modules[Module.Clean] + if speaker := device.modules.get(Module.Speaker): + self._speaker_module = speaker + self._attr_supported_features |= VacuumEntityFeature.LOCATE + + if ( + fanspeed_feat := self._vacuum_module.get_feature("fan_speed_preset") + ) and fanspeed_feat.choices: + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + self._attr_fan_speed_list = [c.lower() for c in fanspeed_feat.choices] + + @async_refresh_after + async def async_start(self) -> None: + """Start cleaning.""" + await self._vacuum_module.start() + + @async_refresh_after + async def async_pause(self) -> None: + """Pause cleaning.""" + await self._vacuum_module.pause() + + @async_refresh_after + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return home.""" + await self._vacuum_module.return_home() + + @async_refresh_after + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._vacuum_module.set_fan_speed_preset(fan_speed.capitalize()) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the device.""" + await self._speaker_module.locate() + + @property + def battery_level(self) -> int | None: + """Return battery level.""" + return self._vacuum_module.battery + + def _async_update_attrs(self) -> bool: + """Update the entity's attributes.""" + self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status) + if self._vacuum_module.has_feature("fan_speed_preset"): + self._attr_fan_speed = self._vacuum_module.fan_speed_preset.lower() + return True diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 2d33a890510..7ea7fd95fef 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -11,7 +11,7 @@ from tplink_omada_client.exceptions import ( UnsupportedControllerVersion, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo ) from ex site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) - controller = OmadaSiteController(hass, site_client) + controller = OmadaSiteController(hass, entry, site_client) await controller.initialize_first_refresh() entry.runtime_data = controller @@ -80,12 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # This is the last loaded instance of Omada, deregister any services hass.services.async_remove(DOMAIN, "reconnect_client") diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py index 73d5f54b8b3..fb179634fd1 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry from .controller import OmadaGatewayCoordinator @@ -28,7 +28,7 @@ from .entity import OmadaDeviceEntity async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 658286981f9..60a07f76b23 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,10 +1,17 @@ """Controller for sharing Omada API coordinators between platforms.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from tplink_omada_client import OmadaSiteClient from tplink_omada_client.devices import OmadaSwitch from homeassistant.core import HomeAssistant +if TYPE_CHECKING: + from . import OmadaConfigEntry + from .coordinator import ( OmadaClientsCoordinator, OmadaDevicesCoordinator, @@ -21,15 +28,21 @@ class OmadaSiteController: def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, ) -> None: """Create the controller.""" self._hass = hass + self._config_entry = config_entry self._omada_client = omada_client self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} - self._devices_coordinator = OmadaDevicesCoordinator(hass, omada_client) - self._clients_coordinator = OmadaClientsCoordinator(hass, omada_client) + self._devices_coordinator = OmadaDevicesCoordinator( + hass, config_entry, omada_client + ) + self._clients_coordinator = OmadaClientsCoordinator( + hass, config_entry, omada_client + ) async def initialize_first_refresh(self) -> None: """Initialize the all coordinators, and perform first refresh.""" @@ -39,7 +52,7 @@ class OmadaSiteController: gateway = next((d for d in devices if d.type == "gateway"), None) if gateway: self._gateway_coordinator = OmadaGatewayCoordinator( - self._hass, self._omada_client, gateway.mac + self._hass, self._config_entry, self._omada_client, gateway.mac ) await self._gateway_coordinator.async_config_entry_first_refresh() @@ -56,7 +69,7 @@ class OmadaSiteController: """Get coordinator for network port information of a given switch.""" if switch.mac not in self._switch_port_coordinators: self._switch_port_coordinators[switch.mac] = OmadaSwitchPortCoordinator( - self._hass, self._omada_client, switch + self._hass, self._config_entry, self._omada_client, switch ) return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index a80bedeb65e..1552b568297 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,8 +1,11 @@ """Generic Omada API coordinator.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import TYPE_CHECKING from tplink_omada_client import OmadaSiteClient, OmadaSwitchPortDetails from tplink_omada_client.clients import OmadaWirelessClient @@ -12,6 +15,9 @@ from tplink_omada_client.exceptions import OmadaClientException from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import OmadaConfigEntry + _LOGGER = logging.getLogger(__name__) POLL_SWITCH_PORT = 300 @@ -23,9 +29,12 @@ POLL_DEVICES = 300 class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): """Coordinator for synchronizing bulk Omada data.""" + config_entry: OmadaConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, name: str, poll_delay: int | None = 300, @@ -34,6 +43,7 @@ class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Omada API Data - {name}", update_interval=timedelta(seconds=poll_delay) if poll_delay else None, ) @@ -58,12 +68,17 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, network_switch: OmadaSwitch, ) -> None: """Initialize my coordinator.""" super().__init__( - hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + hass, + config_entry, + omada_client, + f"{network_switch.name} Ports", + POLL_SWITCH_PORT, ) self._network_switch = network_switch @@ -79,11 +94,12 @@ class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, mac: str, ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY) + super().__init__(hass, config_entry, omada_client, "Gateway", POLL_GATEWAY) self.mac = mac async def poll_update(self) -> dict[str, OmadaGateway]: @@ -98,10 +114,11 @@ class OmadaDevicesCoordinator(OmadaCoordinator[OmadaListDevice]): def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "DeviceList", POLL_CLIENTS) + super().__init__(hass, config_entry, omada_client, "DeviceList", POLL_CLIENTS) async def poll_update(self) -> dict[str, OmadaListDevice]: """Poll the site's current registered Omada devices.""" @@ -111,9 +128,14 @@ class OmadaDevicesCoordinator(OmadaCoordinator[OmadaListDevice]): class OmadaClientsCoordinator(OmadaCoordinator[OmadaWirelessClient]): """Coordinator for getting details about the site's connected clients.""" - def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: OmadaConfigEntry, + omada_client: OmadaSiteClient, + ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "ClientsList", POLL_CLIENTS) + super().__init__(hass, config_entry, omada_client, "ClientsList", POLL_CLIENTS) async def poll_update(self) -> dict[str, OmadaWirelessClient]: """Poll the site's current active wi-fi clients.""" diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py index fe78adf8847..ce1c8ba40e1 100644 --- a/homeassistant/components/tplink_omada/device_tracker.py +++ b/homeassistant/components/tplink_omada/device_tracker.py @@ -6,7 +6,7 @@ from tplink_omada_client.clients import OmadaWirelessClient from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OmadaConfigEntry @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device trackers and scanners.""" diff --git a/homeassistant/components/tplink_omada/sensor.py b/homeassistant/components/tplink_omada/sensor.py index 272334d1b52..b41f3da2f33 100644 --- a/homeassistant/components/tplink_omada/sensor.py +++ b/homeassistant/components/tplink_omada/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import OmadaConfigEntry @@ -57,7 +57,7 @@ def _map_device_status(device: OmadaListDevice) -> str | None: async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index f99d8aaedde..37c73a9e11f 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -22,7 +22,7 @@ from tplink_omada_client.omadasiteclient import GatewayPortSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry from .controller import OmadaGatewayCoordinator, OmadaSwitchPortCoordinator @@ -37,7 +37,7 @@ TCoordinator = TypeVar("TCoordinator", bound="OmadaCoordinator[Any]") async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 54b586794be..8a8531c10b6 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry from .coordinator import POLL_DEVICES, OmadaCoordinator, OmadaDevicesCoordinator @@ -43,7 +43,9 @@ class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # devices_coordinator: OmadaDevicesCoordinator, ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "Firmware Updates", poll_delay=None) + super().__init__( + hass, config_entry, omada_client, "Firmware Updates", poll_delay=None + ) self._devices_coordinator = devices_coordinator self._config_entry = config_entry @@ -91,7 +93,7 @@ class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index fe08c3db234..5b9bc2551b7 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -9,8 +9,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 0fa7fc344ea..43210ee92ea 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, TRACKER_UPDATE @@ -69,7 +69,9 @@ EVENTS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure a dispatcher connection based on a config entry.""" diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py index c7a65d2d4a8..44aeedc3376 100644 --- a/homeassistant/components/traccar_server/__init__.py +++ b/homeassistant/components/traccar_server/__init__.py @@ -21,13 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_track_time_interval -from .const import ( - CONF_CUSTOM_ATTRIBUTES, - CONF_EVENTS, - CONF_MAX_ACCURACY, - CONF_SKIP_ACCURACY_FILTER_FOR, - DOMAIN, -) +from .const import CONF_EVENTS, DOMAIN from .coordinator import TraccarServerCoordinator PLATFORMS: list[Platform] = [ @@ -47,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = TraccarServerCoordinator( hass=hass, + config_entry=entry, client=ApiClient( client_session=client_session, host=entry.data[CONF_HOST], @@ -56,10 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ssl=entry.data[CONF_SSL], verify_ssl=entry.data[CONF_VERIFY_SSL], ), - events=entry.options.get(CONF_EVENTS, []), - max_accuracy=entry.options.get(CONF_MAX_ACCURACY, 0.0), - skip_accuracy_filter_for=entry.options.get(CONF_SKIP_ACCURACY_FILTER_FOR, []), - custom_attributes=entry.options.get(CONF_CUSTOM_ATTRIBUTES, []), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/traccar_server/binary_sensor.py b/homeassistant/components/traccar_server/binary_sensor.py index 58c46502b53..6d81ba84ed4 100644 --- a/homeassistant/components/traccar_server/binary_sensor.py +++ b/homeassistant/components/traccar_server/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TraccarServerCoordinator @@ -55,7 +55,7 @@ TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 95ce42469f1..2c878856cc2 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -22,7 +22,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, EVENTS, LOGGER +from .const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, + EVENTS, + LOGGER, +) from .helpers import get_device, get_first_geofence @@ -46,25 +54,24 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: ApiClient, - *, - events: list[str], - max_accuracy: float, - skip_accuracy_filter_for: list[str], - custom_attributes: list[str], ) -> None: """Initialize global Traccar Server data updater.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=None, ) self.client = client - self.custom_attributes = custom_attributes - self.events = events - self.max_accuracy = max_accuracy - self.skip_accuracy_filter_for = skip_accuracy_filter_for + self.custom_attributes = config_entry.options.get(CONF_CUSTOM_ATTRIBUTES, []) + self.events = config_entry.options.get(CONF_EVENTS, []) + self.max_accuracy = config_entry.options.get(CONF_MAX_ACCURACY, 0.0) + self.skip_accuracy_filter_for = config_entry.options.get( + CONF_SKIP_ACCURACY_FILTER_FOR, [] + ) self._geofences: list[GeofenceModel] = [] self._last_event_import: datetime | None = None self._should_log_subscription_error: bool = True diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index 9e5a3c0ee9f..7f2a6dd7c40 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_CATEGORY, ATTR_TRACCAR_ID, ATTR_TRACKER, DOMAIN from .coordinator import TraccarServerCoordinator @@ -17,7 +17,7 @@ from .entity import TraccarServerEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker entities.""" coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py index bb3c4ed4401..9aee6f28489 100644 --- a/homeassistant/components/traccar_server/sensor.py +++ b/homeassistant/components/traccar_server/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -83,7 +83,7 @@ TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS: tuple[ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 9ff645ce4d6..bb0f3e5251a 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index e8ef417ca5f..3c503efdd28 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -15,9 +15,8 @@ from homeassistant.helpers.trace import ( trace_id_set, trace_set_child_id, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, uuid as uuid_util from homeassistant.util.limited_size_dict import LimitedSizeDict -import homeassistant.util.uuid as uuid_util type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 80219154d81..2978d369344 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry from .const import TRACKER_HARDWARE_STATUS_UPDATED @@ -58,7 +58,7 @@ SENSOR_TYPE = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index f31afaf92f6..73be7216a2f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( @@ -21,7 +21,7 @@ from .entity import TractiveEntity async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index a3c1893267c..18d7e4c23ab 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import Trackables, TractiveClient, TractiveConfigEntry @@ -182,7 +182,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 3bf6887e99c..da2c8e35ff7 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -11,7 +11,7 @@ from aiotractive.exceptions import TractiveError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( @@ -57,7 +57,7 @@ SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive switches.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 0060310e6c2..2073829e021 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -106,7 +106,7 @@ async def async_setup_entry( for device in devices: coordinator = TradfriDeviceDataUpdateCoordinator( - hass=hass, api=api, device=device + hass=hass, config_entry=entry, api=api, device=device ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index d9911472a67..29d876346a7 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -10,10 +10,13 @@ from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN @@ -78,12 +81,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle homekit discovery.""" - await self.async_set_unique_id( - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] - ) + await self.async_set_unique_id(discovery_info.properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured({CONF_HOST: discovery_info.host}) host = discovery_info.host @@ -96,7 +97,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if not entry.unique_id: self.hass.config_entries.async_update_entry( entry, - unique_id=discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID], + unique_id=discovery_info.properties[ATTR_PROPERTIES_ID], ) return self.async_abort(reason="already_configured") diff --git a/homeassistant/components/tradfri/coordinator.py b/homeassistant/components/tradfri/coordinator.py index 5246545ae65..4c5c186626e 100644 --- a/homeassistant/components/tradfri/coordinator.py +++ b/homeassistant/components/tradfri/coordinator.py @@ -10,6 +10,7 @@ from pytradfri.command import Command from pytradfri.device import Device from pytradfri.error import RequestError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,10 +22,12 @@ SCAN_INTERVAL = 60 # Interval for updating the coordinator class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Coordinator to manage data for a specific Tradfri device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, + config_entry: ConfigEntry, api: Callable[[Command | list[Command]], Any], device: Device, ) -> None: @@ -36,6 +39,7 @@ class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"Update coordinator for {device}", update_interval=timedelta(seconds=SCAN_INTERVAL), ) diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 92d10320327..b1fb9b153ad 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -10,7 +10,7 @@ from pytradfri.command import Command from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri covers based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index 3f45ee3e1eb..e8fb7c050ed 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -10,7 +10,7 @@ from pytradfri.command import Command from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -33,7 +33,7 @@ def _from_fan_speed(fan_speed: int) -> int: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a71691e6e90..b945c7f2bec 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -19,8 +19,8 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -30,7 +30,7 @@ from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri lights based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 4e560f0e7b5..b4a7c335481 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_GATEWAY_ID, @@ -128,7 +128,7 @@ def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tradfri config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 088b775b9fd..a2a1a5b4623 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -10,7 +10,7 @@ from pytradfri.command import Command from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index b367fa0fb45..92112b41466 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TVCameraConfigEntry from .coordinator import CameraData @@ -36,7 +36,7 @@ BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: TVCameraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Trafikverket Camera binary sensor platform.""" diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index ece02cacf70..b4eddb0890f 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.camera import Camera from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TVCameraConfigEntry from .const import ATTR_DESCRIPTION, ATTR_TYPE @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TVCameraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Trafikverket Camera.""" diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index cb5c458f742..726fcb6f901 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TVCameraConfigEntry @@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVCameraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Trafikverket Camera sensor platform.""" diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index 44176ab82b7..b908bc5f550 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import as_utc @@ -92,7 +92,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVFerryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index d09077dd01a..19f88817e71 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -4,11 +4,21 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + TrafikverketTrain, + UnknownError, +) -from .const import PLATFORMS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_FROM, CONF_TO, PLATFORMS from .coordinator import TVDataUpdateCoordinator TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator] @@ -52,13 +62,55 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 1: + if entry.version > 2: # This means the user has downgraded from a future version return False - if entry.version == 1 and entry.minor_version == 1: - # Remove unique id - hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2) + if entry.version == 1: + if entry.minor_version == 1: + # Remove unique id + hass.config_entries.async_update_entry( + entry, unique_id=None, minor_version=2 + ) + + # Change from station names to station signatures + try: + web_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(web_session, entry.data[CONF_API_KEY]) + from_stations = await train_api.async_search_train_stations( + entry.data[CONF_FROM] + ) + to_stations = await train_api.async_search_train_stations( + entry.data[CONF_TO] + ) + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except NoTrainStationFound as error: + _LOGGER.error( + "Migration failed as no train station found with provided name %s", + str(error), + ) + return False + except UnknownError as error: + _LOGGER.error("Unknown error occurred during validation %s", str(error)) + return False + except Exception as error: # noqa: BLE001 + _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + return False + + if len(from_stations) > 1 or len(to_stations) > 1: + _LOGGER.error( + "Migration failed as more than one station found with provided name" + ) + return False + + new_data = entry.data.copy() + new_data[CONF_FROM] = from_stations[0].signature + new_data[CONF_TO] = to_stations[0].signature + + hass.config_entries.async_update_entry( + entry, data=new_data, version=2, minor_version=1 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 363b9bb2542..f6a58e464a1 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -3,21 +3,20 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime import logging from typing import Any -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, UnknownError, ) import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -25,19 +24,18 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, TextSelector, TimeSelector, ) -import homeassistant.util.dt as dt_util from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN -from .util import next_departuredate _LOGGER = logging.getLogger(__name__) @@ -68,49 +66,23 @@ DATA_SCHEMA_REAUTH = vol.Schema( ) -async def validate_input( +async def validate_station( hass: HomeAssistant, api_key: str, - train_from: str, - train_to: str, - train_time: str | None, - weekdays: list[str], - product_filter: str | None, -) -> dict[str, str]: + train_station: str, + field: str, +) -> tuple[list[StationInfoModel], dict[str, str]]: """Validate input from user input.""" errors: dict[str, str] = {} - - when = dt_util.now() - if train_time: - departure_day = next_departuredate(weekdays) - if _time := dt_util.parse_time(train_time): - when = datetime.combine( - departure_day, - _time, - dt_util.get_default_time_zone(), - ) - + stations = [] try: web_session = async_get_clientsession(hass) train_api = TrafikverketTrain(web_session, api_key) - from_station = await train_api.async_search_train_station(train_from) - to_station = await train_api.async_search_train_station(train_to) - if train_time: - await train_api.async_get_train_stop( - from_station, to_station, when, product_filter - ) - else: - await train_api.async_get_next_train_stop( - from_station, to_station, when, product_filter - ) + stations = await train_api.async_search_train_stations(train_station) except InvalidAuthentication: errors["base"] = "invalid_auth" except NoTrainStationFound: - errors["base"] = "invalid_station" - except MultipleTrainStationsFound: - errors["base"] = "more_stations" - except NoTrainAnnouncementFound: - errors["base"] = "no_trains" + errors[field] = "invalid_station" except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" @@ -118,14 +90,21 @@ async def validate_input( _LOGGER.error("Unknown exception occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - return errors + return (stations, errors) class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Train integration.""" - VERSION = 1 - MINOR_VERSION = 2 + VERSION = 2 + MINOR_VERSION = 1 + + _from_stations: list[StationInfoModel] + _to_stations: list[StationInfoModel] + _time: str | None + _days: list + _product: str | None + _data: dict[str, Any] @staticmethod @callback @@ -151,14 +130,11 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] reauth_entry = self._get_reauth_entry() - errors = await validate_input( + _, errors = await validate_station( self.hass, api_key, reauth_entry.data[CONF_FROM], - reauth_entry.data[CONF_TO], - reauth_entry.data.get(CONF_TIME), - reauth_entry.data[CONF_WEEKDAY], - reauth_entry.options.get(CONF_FILTER_PRODUCT), + CONF_FROM, ) if not errors: return self.async_update_reload_and_abort( @@ -174,6 +150,18 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + return await self.async_step_initial(user_input) + + async def async_step_initial( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step.""" errors: dict[str, str] = {} @@ -193,27 +181,101 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): if train_time: name = f"{train_from} to {train_to} at {train_time}" - errors = await validate_input( - self.hass, - api_key, - train_from, - train_to, - train_time, - train_days, - filter_product, + self._from_stations, from_errors = await validate_station( + self.hass, api_key, train_from, CONF_FROM ) + self._to_stations, to_errors = await validate_station( + self.hass, api_key, train_to, CONF_TO + ) + errors = {**from_errors, **to_errors} + if not errors: - self._async_abort_entries_match( - { - CONF_API_KEY: api_key, - CONF_FROM: train_from, - CONF_TO: train_to, - CONF_TIME: train_time, - CONF_WEEKDAY: train_days, - CONF_FILTER_PRODUCT: filter_product, - } + if len(self._from_stations) == 1 and len(self._to_stations) == 1: + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: self._from_stations[0].signature, + CONF_TO: self._to_stations[0].signature, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + self._data = user_input + return await self.async_step_select_stations() + + return self.async_show_form( + step_id="initial", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input or {} + ), + errors=errors, + ) + + async def async_step_select_stations( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the select station step.""" + if user_input is not None: + api_key: str = self._data[CONF_API_KEY] + train_from: str = ( + user_input.get(CONF_FROM) or self._from_stations[0].signature + ) + train_to: str = user_input.get(CONF_TO) or self._to_stations[0].signature + train_time: str | None = self._data.get(CONF_TIME) + train_days: list = self._data[CONF_WEEKDAY] + filter_product: str | None = self._data[CONF_FILTER_PRODUCT] + + if filter_product == "": + filter_product = None + + name = f"{self._data[CONF_FROM]} to {self._data[CONF_TO]}" + if train_time: + name = ( + f"{self._data[CONF_FROM]} to {self._data[CONF_TO]} at {train_time}" ) - return self.async_create_entry( + self._async_abort_entries_match( + { + CONF_API_KEY: api_key, + CONF_FROM: train_from, + CONF_TO: user_input[CONF_TO], + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + CONF_FILTER_PRODUCT: filter_product, + } + ) + if self.source == SOURCE_RECONFIGURE: + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, title=name, data={ CONF_API_KEY: api_key, @@ -225,13 +287,45 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): }, options={CONF_FILTER_PRODUCT: filter_product}, ) + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) + from_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._from_stations + ] + to_options = [ + SelectOptionDict(value=station.signature, label=station.station_name) + for station in self._to_stations + ] + schema = {} + if len(from_options) > 1: + schema[vol.Required(CONF_FROM)] = SelectSelector( + SelectSelectorConfig( + options=from_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) + if len(to_options) > 1: + schema[vol.Required(CONF_TO)] = SelectSelector( + SelectSelectorConfig( + options=to_options, mode=SelectSelectorMode.DROPDOWN, sort=True + ) + ) return self.async_show_form( - step_id="user", + step_id="select_stations", data_schema=self.add_suggested_values_to_schema( - DATA_SCHEMA, user_input or {} + vol.Schema(schema), user_input or {} ), - errors=errors, ) diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index c4e1a418371..28c9ab6fe8e 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -7,15 +7,16 @@ from datetime import datetime, time, timedelta import logging from typing import TYPE_CHECKING -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, MultipleTrainStationsFound, NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrafikverketTrain, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import StationInfoModel, TrainStopModel from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY from homeassistant.core import HomeAssistant @@ -93,11 +94,15 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): async def _async_setup(self) -> None: """Initiate stations.""" try: - self.to_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_TO] + self.to_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_TO] + ) ) - self.from_station = await self._train_api.async_search_train_station( - self.config_entry.data[CONF_FROM] + self.from_station = ( + await self._train_api.async_get_train_station_from_signature( + self.config_entry.data[CONF_FROM] + ) ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index a4de8c1ef26..150b5ee7abb 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -110,7 +110,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVTrainConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 89542211a92..02155e46c2f 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -13,7 +14,7 @@ "incorrect_api_key": "Invalid API key for selected account" }, "step": { - "user": { + "initial": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "to": "To station", @@ -27,6 +28,13 @@ "filter_product": "To filter by product description add the phrase here to match" } }, + "select_stations": { + "description": "More than one station was found with the provided name, select the correct ones from the provided lists", + "data": { + "to": "To station", + "from": "From station" + } + }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" @@ -38,10 +46,10 @@ "step": { "init": { "data": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data::filter_product%]" }, "data_description": { - "filter_product": "[%key:component::trafikverket_train::config::step::user::data_description::filter_product%]" + "filter_product": "[%key:component::trafikverket_train::config::step::initial::data_description::filter_product%]" } } } diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 28b9a124fc6..f4316b887b3 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index bc17c82748a..cb923037a24 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVWeatherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 1a8ffdea0c2..6d23017ab75 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -15,7 +15,7 @@ from transmission_rpc.error import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -54,7 +54,7 @@ from .const import ( SERVICE_START_TORRENT, SERVICE_STOP_TORRENT, ) -from .coordinator import TransmissionDataUpdateCoordinator +from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator from .errors import AuthenticationError, CannotConnect, UnknownError _LOGGER = logging.getLogger(__name__) @@ -78,7 +78,9 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector( + {"integration": DOMAIN} + ), } ) @@ -115,8 +117,6 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All( CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Transmission component.""" @@ -165,12 +165,16 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: TransmissionConfigEntry +) -> bool: """Unload Transmission Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TransmissionConfigEntry +) -> bool: """Migrate an old config entry.""" _LOGGER.debug( "Migrating from version %s.%s", diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index b998ab6fbdd..afe2660e711 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -27,17 +27,21 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] + class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): """Transmission dataupdate coordinator class.""" - config_entry: ConfigEntry + config_entry: TransmissionConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, api: transmission_rpc.Client + self, + hass: HomeAssistant, + entry: TransmissionConfigEntry, + api: transmission_rpc.Client, ) -> None: """Initialize the Transmission RPC API.""" - self.config_entry = entry self.api = api self.host = entry.data[CONF_HOST] self._session: transmission_rpc.Session | None = None @@ -47,6 +51,7 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): self.torrents: list[transmission_rpc.Torrent] = [] super().__init__( hass, + config_entry=entry, name=f"{DOMAIN} - {self.host}", logger=_LOGGER, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 652f5d51fbb..a0babe7464a 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionConfigEntry from .const import ( DOMAIN, STATE_ATTR_TORRENT_INFO, @@ -30,7 +29,7 @@ from .const import ( STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) -from .coordinator import TransmissionDataUpdateCoordinator +from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator MODES: dict[str, list[str] | None] = { "started_torrents": ["downloading"], @@ -130,7 +129,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TransmissionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Transmission sensors.""" diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index aabc5827a88..0fe1953d31e 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up Transmission Client", + "title": "Set up Transmission client", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", @@ -96,7 +96,7 @@ "fields": { "entry_id": { "name": "Transmission entry", - "description": "Config entry id." + "description": "ID of the config entry to use." }, "torrent": { "name": "Torrent", diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index d88f794cb10..9ca8a197344 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -7,12 +7,11 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionConfigEntry from .const import DOMAIN -from .coordinator import TransmissionDataUpdateCoordinator +from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -45,7 +44,7 @@ SWITCH_TYPES: tuple[TransmissionSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TransmissionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Transmission switch.""" diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 5628274b967..49a11a57f65 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index fe4a6541d9e..8193c5a67dc 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 9691ecf0744..4261f96bbe6 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -32,11 +32,13 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.restore_state import RestoreEntity @@ -131,7 +133,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up trend sensor from config entry.""" diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 69e8daa3ce7..16c7067c7ce 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.2.1"] + "requirements": ["numpy==2.2.2"] } diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py index 94566fe301d..e04cf5ee7e8 100644 --- a/homeassistant/components/triggercmd/switch.py +++ b/homeassistant/components/triggercmd/switch.py @@ -9,7 +9,7 @@ from triggercmd import client, ha from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TriggercmdConfigEntry from .const import DOMAIN @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: TriggercmdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switch for passed config_entry in HA.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index e7d1091719b..98ce76cafde 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import AsyncGenerator +from dataclasses import dataclass from datetime import datetime -from functools import partial import hashlib from http import HTTPStatus import io @@ -16,40 +16,35 @@ import re import secrets import subprocess import tempfile -from typing import Any, Final, TypedDict, final +from time import monotonic +from typing import Any, Final, TypedDict from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import ( - ATTR_MEDIA_ANNOUNCE, - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, - SERVICE_PLAY_MEDIA, - MediaType, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - PLATFORM_FORMAT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + ServiceCall, + callback, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import dt as dt_util, language as language_util +from homeassistant.util import language as language_util from .const import ( ATTR_CACHE, @@ -67,29 +62,31 @@ from .const import ( DOMAIN, TtsAudioType, ) +from .entity import TextToSpeechEntity from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs from .models import Voice __all__ = [ - "async_default_engine", - "async_get_media_source_audio", - "async_support_options", "ATTR_AUDIO_OUTPUT", "ATTR_PREFERRED_FORMAT", - "ATTR_PREFERRED_SAMPLE_RATE", - "ATTR_PREFERRED_SAMPLE_CHANNELS", "ATTR_PREFERRED_SAMPLE_BYTES", + "ATTR_PREFERRED_SAMPLE_CHANNELS", + "ATTR_PREFERRED_SAMPLE_RATE", "CONF_LANG", "DEFAULT_CACHE_DIR", - "generate_media_source_id", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", - "SampleFormat", + "PLATFORM_SCHEMA_BASE", "Provider", + "ResultStream", + "SampleFormat", + "TextToSpeechEntity", "TtsAudioType", "Voice", + "async_default_engine", + "async_get_media_source_audio", + "generate_media_source_id", ] _LOGGER = logging.getLogger(__name__) @@ -129,9 +126,10 @@ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) class TTSCache(TypedDict): """Cached TTS file.""" - filename: str + extension: str voice: bytes pending: asyncio.Task | None + last_used: float @callback @@ -169,22 +167,19 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: return async_default_engine(hass) -async def async_support_options( +@callback +def async_create_stream( hass: HomeAssistant, engine: str, language: str | None = None, options: dict | None = None, -) -> bool: - """Return if an engine supports options.""" - if (engine_instance := get_engine_instance(hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - try: - hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) - except HomeAssistantError: - return False - - return True +) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" + return hass.data[DATA_TTS_MANAGER].async_create_result_stream( + engine=engine, + language=language, + options=options, + ) async def async_get_media_source_audio( @@ -192,9 +187,11 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio( - **media_source_id_to_kwargs(media_source_id), + manager = hass.data[DATA_TTS_MANAGER] + cache_key = manager.async_cache_message_in_memory( + **media_source_id_to_kwargs(media_source_id) ) + return await manager.async_get_tts_audio(cache_key) @callback @@ -306,11 +303,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Legacy config options conf = config[DOMAIN][0] if config.get(DOMAIN) else {} - use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + use_file_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + memory_cache_maxage: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - tts = SpeechManager(hass, use_cache, cache_dir, time_memory) + tts = SpeechManager(hass, use_file_cache, cache_dir, memory_cache_maxage) try: await tts.async_init_cache() @@ -375,140 +372,55 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -CACHED_PROPERTIES_WITH_ATTR_ = { - "default_language", - "default_options", - "supported_languages", - "supported_options", -} +@dataclass +class ResultStream: + """Class that will stream the result when available.""" + # Streaming/conversion properties + token: str + extension: str + content_type: str -class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): - """Represent a single TTS engine.""" + # TTS properties + engine: str + use_file_cache: bool + language: str + options: dict - _attr_should_poll = False - __last_tts_loaded: str | None = None - - _attr_default_language: str - _attr_default_options: Mapping[str, Any] | None = None - _attr_supported_languages: list[str] - _attr_supported_options: list[str] | None = None - - @property - @final - def state(self) -> str | None: - """Return the state of the entity.""" - if self.__last_tts_loaded is None: - return None - return self.__last_tts_loaded + _manager: SpeechManager @cached_property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return self._attr_supported_languages + def url(self) -> str: + """Get the URL to stream the result.""" + return f"/api/tts_proxy/{self.token}" @cached_property - def default_language(self) -> str: - """Return the default language.""" - return self._attr_default_language - - @cached_property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return self._attr_supported_options - - @cached_property - def default_options(self) -> Mapping[str, Any] | None: - """Return a mapping with the default options.""" - return self._attr_default_options + def _result_cache_key(self) -> asyncio.Future[str]: + """Get the future that returns the cache key.""" + return asyncio.Future() @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: - """Return a list of supported voices for a language.""" - return None + def async_set_message_cache_key(self, cache_key: str) -> None: + """Set cache key for message to be streamed.""" + self._result_cache_key.set_result(cache_key) - async def async_internal_added_to_hass(self) -> None: - """Call when the entity is added to hass.""" - await super().async_internal_added_to_hass() - try: - _ = self.default_language - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - ) from err - try: - _ = self.supported_languages - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - ) from err - state = await self.async_get_last_state() - if ( - state is not None - and state.state is not None - and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ): - self.__last_tts_loaded = state.state - - async def async_speak( - self, - media_player_entity_id: list[str], - message: str, - cache: bool, - language: str | None = None, - options: dict | None = None, - ) -> None: - """Speak via a Media Player.""" - await self.hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player_entity_id, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - self.hass, - message=message, - engine=self.entity_id, - language=language, - options=options, - cache=cache, - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=self._context, + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + cache_key = self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, ) + self._result_cache_key.set_result(cache_key) - @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Process an audio stream to TTS service. - - Only streaming content is allowed! - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options - ) - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine.""" - raise NotImplementedError - - async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine. - - Return a tuple of file extension and data as bytes. - """ - return await self.hass.async_add_executor_job( - partial(self.get_tts_audio, message, language, options=options) - ) + async def async_stream_result(self) -> AsyncGenerator[bytes]: + """Get the stream of this result.""" + cache_key = await self._result_cache_key + _extension, data = await self._manager.async_get_tts_audio(cache_key) + yield data def _hash_options(options: dict) -> str: @@ -521,29 +433,82 @@ def _hash_options(options: dict) -> str: return opts_hash.hexdigest() +class MemcacheCleanup: + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, hass: HomeAssistant, maxage: float, memcache: dict[str, TTSCache] + ) -> None: + """Initialize the cleanup.""" + self.hass = hass + self.maxage = maxage + self.memcache = memcache + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + self.maxage + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, _now: datetime) -> None: + """Clean up and schedule follow-up if necessary.""" + self.unsub = None + memcache = self.memcache + maxage = self.maxage + now = monotonic() + + for cache_key, info in list(memcache.items()): + if info["last_used"] + maxage < now: + _LOGGER.debug("Cleaning up %s", cache_key) + del memcache[cache_key] + + # Still items left, check again in timeout time. + if memcache: + self.schedule() + + class SpeechManager: """Representation of a speech store.""" def __init__( self, hass: HomeAssistant, - use_cache: bool, + use_file_cache: bool, cache_dir: str, - time_memory: int, + memory_cache_maxage: int, ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = use_cache + self.use_file_cache = use_file_cache self.cache_dir = cache_dir - self.time_memory = time_memory + self.memory_cache_maxage = memory_cache_maxage self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - - # filename <-> token - self.filename_to_token: dict[str, str] = {} - self.token_to_filename: dict[str, str] = {} + self.token_to_stream: dict[str, ResultStream] = {} + self.memcache_cleanup = MemcacheCleanup( + hass, memory_cache_maxage, self.mem_cache + ) def _init_cache(self) -> dict[str, str]: """Init cache folder and fetch files.""" @@ -563,18 +528,21 @@ class SpeechManager: async def async_clear_cache(self) -> None: """Read file cache and delete files.""" - self.mem_cache = {} + self.mem_cache.clear() - def remove_files() -> None: + def remove_files(files: list[str]) -> None: """Remove files from filesystem.""" - for filename in self.file_cache.values(): + for filename in files: try: os.remove(os.path.join(self.cache_dir, filename)) except OSError as err: _LOGGER.warning("Can't remove cache file '%s': %s", filename, err) - await self.hass.async_add_executor_job(remove_files) - self.file_cache = {} + task = self.hass.async_add_executor_job( + remove_files, list(self.file_cache.values()) + ) + self.file_cache.clear() + await task @callback def async_register_legacy_engine( @@ -629,107 +597,153 @@ class SpeechManager: return language, merged_options - async def async_get_url_path( + @callback + def async_create_result_stream( self, engine: str, - message: str, - cache: bool | None = None, + message: str | None = None, + use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: - """Get URL for play message. - - This method is a coroutine. - """ + ) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache + if use_file_cache is None: + use_file_cache = self.use_file_cache - # Is speech already in memory - if cache_key in self.mem_cache: - filename = self.mem_cache[cache_key]["filename"] - # Is file store in file cache - elif use_cache and cache_key in self.file_cache: - filename = self.file_cache[cache_key] - self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from engine into memory - else: - filename = await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + token = f"{secrets.token_urlsafe(16)}.{extension}" + content, _ = mimetypes.guess_type(token) + result_stream = ResultStream( + token=token, + extension=extension, + content_type=content or "audio/mpeg", + use_file_cache=use_file_cache, + engine=engine, + language=language, + options=options, + _manager=self, + ) + self.token_to_stream[token] = result_stream - # Use a randomly generated token instead of exposing the filename - token = self.filename_to_token.get(filename) - if not token: - # Keep extension (.mp3, etc.) - token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1] + if message is None: + return result_stream - # Map token <-> filename - self.filename_to_token[filename] = token - self.token_to_filename[token] = filename + cache_key = self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + result_stream.async_set_message_cache_key(cache_key) - return f"/api/tts_proxy/{token}" - - async def async_get_tts_audio( - self, - engine: str, - message: str, - cache: bool | None = None, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, bytes]: - """Fetch TTS audio.""" - if (engine_instance := get_engine_instance(self.hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache - - # If we have the file, load it into memory if necessary - if cache_key not in self.mem_cache: - if use_cache and cache_key in self.file_cache: - await self._async_file_to_mem(cache_key) - else: - await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) - - extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - return extension, cached["voice"] + return result_stream @callback - def _generate_cache_key( + def async_cache_message_in_memory( self, - message: str, - language: str, - options: dict | None, engine: str, + message: str, + use_file_cache: bool | None = None, + language: str | None = None, + options: dict | None = None, ) -> str: - """Generate a cache key for a message.""" + """Make sure a message is cached in memory and returns cache key.""" + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) + if use_file_cache is None: + use_file_cache = self.use_file_cache + + return self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + + @callback + def _async_ensure_cached_in_memory( + self, + engine: str, + engine_instance: TextToSpeechEntity | Provider, + message: str, + use_file_cache: bool, + language: str, + options: dict, + ) -> str: + """Ensure a message is cached. + + Requires options, language to be processed. + """ options_key = _hash_options(options) if options else "-" msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest() - return KEY_PATTERN.format( + cache_key = KEY_PATTERN.format( msg_hash, language.replace("_", "-"), options_key, engine ).lower() - async def _async_get_tts_audio( + # Is speech already in memory + if cache_key in self.mem_cache: + return cache_key + + if use_file_cache and cache_key in self.file_cache: + coro = self._async_load_file_to_mem(cache_key) + else: + coro = self._async_generate_tts_audio( + engine_instance, cache_key, message, use_file_cache, language, options + ) + + task = self.hass.async_create_task(coro, eager_start=False) + + def handle_error(future: asyncio.Future) -> None: + """Handle error.""" + if not (err := future.exception()): + return + # Truncate message so we don't flood the logs. Cutting off at 32 chars + # but since we add 3 dots to truncated message, we cut off at 35. + trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" + _LOGGER.error("Error generating audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache_key, None) + + task.add_done_callback(handle_error) + + self.mem_cache[cache_key] = { + "extension": "", + "voice": b"", + "pending": task, + "last_used": monotonic(), + } + return cache_key + + async def async_get_tts_audio(self, cache_key: str) -> tuple[str, bytes]: + """Fetch TTS audio.""" + cached = self.mem_cache.get(cache_key) + if cached is None: + raise HomeAssistantError("Audio not cached") + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + cached["last_used"] = monotonic() + return cached["extension"], cached["voice"] + + async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, cache_key: str, message: str, - cache: bool, + cache_to_disk: bool, language: str, options: dict[str, Any], - ) -> str: - """Receive TTS, store for view in cache and return filename. + ) -> None: + """Start loading of the TTS audio. This method is a coroutine. """ @@ -773,96 +787,66 @@ class SpeechManager: if sample_bytes is not None: sample_bytes = int(sample_bytes) - async def get_tts_data() -> str: - """Handle data available.""" - if engine_instance.name is None or engine_instance.name is UNDEFINED: - raise HomeAssistantError("TTS engine name is not set.") + if engine_instance.name is None or engine_instance.name is UNDEFINED: + raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider): - extension, data = await engine_instance.async_get_tts_audio( - message, language, options - ) - else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options - ) - - if data is None or extension is None: - raise HomeAssistantError( - f"No TTS from {engine_instance.name} for '{message}'" - ) - - # Only convert if we have a preferred format different than the - # expected format from the TTS system, or if a specific sample - # rate/format/channel count is requested. - needs_conversion = ( - (final_extension != extension) - or (sample_rate is not None) - or (sample_channels is not None) - or (sample_bytes is not None) + if isinstance(engine_instance, Provider): + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) + else: + extension, data = await engine_instance.internal_async_get_tts_audio( + message, language, options ) - if needs_conversion: - data = await async_convert_audio( - self.hass, - extension, - data, - to_extension=final_extension, - to_sample_rate=sample_rate, - to_sample_channels=sample_channels, - to_sample_bytes=sample_bytes, - ) + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (sample_rate is not None) + or (sample_channels is not None) + or (sample_bytes is not None) + ) - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, filename, data) - - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) - - return filename - - audio_task = self.hass.async_create_task(get_tts_data(), eager_start=False) - - def handle_error(_future: asyncio.Future) -> None: - """Handle error.""" - if audio_task.exception(): - self.mem_cache.pop(cache_key, None) - - audio_task.add_done_callback(handle_error) + if needs_conversion: + data = await async_convert_audio( + self.hass, + extension, + data, + to_extension=final_extension, + to_sample_rate=sample_rate, + to_sample_channels=sample_channels, + to_sample_bytes=sample_bytes, + ) + # Create file infos filename = f"{cache_key}.{final_extension}".lower() - self.mem_cache[cache_key] = { - "filename": filename, - "voice": b"", - "pending": audio_task, - } - return filename - async def _async_save_tts_audio( - self, cache_key: str, filename: str, data: bytes - ) -> None: - """Store voice data to file and file_cache. + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + # Save to memory + if final_extension == "mp3": + data = self.write_tags( + filename, data, engine_instance.name, message, language, options + ) + + self._async_store_to_memcache(cache_key, final_extension, data) + + if not cache_to_disk: + return - This method is a coroutine. - """ voice_file = os.path.join(self.cache_dir, filename) def save_speech() -> None: @@ -870,13 +854,19 @@ class SpeechManager: with open(voice_file, "wb") as speech: speech.write(data) - try: - await self.hass.async_add_executor_job(save_speech) - self.file_cache[cache_key] = filename - except OSError as err: - _LOGGER.error("Can't write %s: %s", filename, err) + # Don't await, we're going to do this in the background + task = self.hass.async_add_executor_job(save_speech) - async def _async_file_to_mem(self, cache_key: str) -> None: + def write_done(future: asyncio.Future) -> None: + """Write is done task.""" + if err := future.exception(): + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache_key] = filename + + task.add_done_callback(write_done) + + async def _async_load_file_to_mem(self, cache_key: str) -> None: """Load voice from file cache into memory. This method is a coroutine. @@ -897,64 +887,22 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - self._async_store_to_memcache(cache_key, filename, data) + extension = os.path.splitext(filename)[1][1:] + + self._async_store_to_memcache(cache_key, extension, data) @callback def _async_store_to_memcache( - self, cache_key: str, filename: str, data: bytes + self, cache_key: str, extension: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" self.mem_cache[cache_key] = { - "filename": filename, + "extension": extension, "voice": data, "pending": None, + "last_used": monotonic(), } - - @callback - def async_remove_from_mem(_: datetime) -> None: - """Cleanup memcache.""" - self.mem_cache.pop(cache_key, None) - - async_call_later( - self.hass, - self.time_memory, - HassJob( - async_remove_from_mem, - name="tts remove_from_mem", - cancel_on_shutdown=True, - ), - ) - - async def async_read_tts(self, token: str) -> tuple[str | None, bytes]: - """Read a voice file and return binary. - - This method is a coroutine. - """ - filename = self.token_to_filename.get(token) - if not filename: - raise HomeAssistantError(f"{token} was not recognized!") - - if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( - record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) - ): - raise HomeAssistantError("Wrong tts file format!") - - cache_key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3), record.group(4) - ) - - if cache_key not in self.mem_cache: - if cache_key not in self.file_cache: - raise HomeAssistantError(f"{cache_key} not in cache!") - await self._async_file_to_mem(cache_key) - - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - - content, _ = mimetypes.guess_type(filename) - return content, cached["voice"] + self.memcache_cleanup.schedule() @staticmethod def write_tags( @@ -1042,9 +990,9 @@ class TextToSpeechUrlView(HomeAssistantView): url = "/api/tts_get_url" name = "api:tts:geturl" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager async def post(self, request: web.Request) -> web.Response: """Generate speech and provide url.""" @@ -1052,10 +1000,8 @@ class TextToSpeechUrlView(HomeAssistantView): data = await request.json() except ValueError: return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST) - if ( - not data.get("engine_id") - and not data.get(ATTR_PLATFORM) - or not data.get(ATTR_MESSAGE) + if (not data.get("engine_id") and not data.get(ATTR_PLATFORM)) or not data.get( + ATTR_MESSAGE ): return self.json_message( "Must specify platform and message", HTTPStatus.BAD_REQUEST @@ -1063,45 +1009,65 @@ class TextToSpeechUrlView(HomeAssistantView): engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] - cache = data.get(ATTR_CACHE) + use_file_cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) options = data.get(ATTR_OPTIONS) try: - path = await self.tts.async_get_url_path( - engine, message, cache=cache, language=language, options=options + stream = self.manager.async_create_result_stream( + engine, + message, + use_file_cache=use_file_cache, + language=language, + options=options, ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) - base = get_url(self.tts.hass) - url = base + path + base = get_url(self.manager.hass) + url = base + stream.url - return self.json({"url": url, "path": path}) + return self.json({"url": url, "path": stream.url}) class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" requires_auth = False - url = "/api/tts_proxy/{filename}" + url = "/api/tts_proxy/{token}" name = "api:tts_speech" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager - async def get(self, request: web.Request, filename: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" - try: - # filename is actually token, but we keep its name for compatibility - content, data = await self.tts.async_read_tts(filename) - except HomeAssistantError as err: - _LOGGER.error("Error on load tts: %s", err) + stream = self.manager.token_to_stream.get(token) + + if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) - return web.Response(body=data, content_type=content) + response: web.StreamResponse | None = None + try: + async for data in stream.async_stream_result(): + if response is None: + response = web.StreamResponse() + response.content_type = stream.content_type + await response.prepare(request) + + await response.write(data) + # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error streaming tts: %s", err) + + # Empty result or exception happened + if response is None: + return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + await response.write_eof() + return response @websocket_api.websocket_command( diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py new file mode 100644 index 00000000000..ef65886452d --- /dev/null +++ b/homeassistant/components/tts/entity.py @@ -0,0 +1,159 @@ +"""Entity for Text-to-Speech.""" + +from collections.abc import Mapping +from functools import partial +from typing import Any, final + +from propcache.api import cached_property + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import TtsAudioType +from .media_source import generate_media_source_id +from .models import Voice + +CACHED_PROPERTIES_WITH_ATTR_ = { + "default_language", + "default_options", + "supported_languages", + "supported_options", +} + + +class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + _attr_default_language: str + _attr_default_options: Mapping[str, Any] | None = None + _attr_supported_languages: list[str] + _attr_supported_options: list[str] | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @cached_property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._attr_supported_languages + + @cached_property + def default_language(self) -> str: + """Return the default language.""" + return self._attr_default_language + + @cached_property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return self._attr_supported_options + + @cached_property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return self._attr_default_options + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + try: + _ = self.default_language + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + ) from err + try: + _ = self.supported_languages + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + ) from err + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio( + message=message, language=language, options=options + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + raise NotImplementedError + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 54ea89cb674..6f0541734d1 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -27,8 +27,7 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 4f1fa59f001..aa2cd6e7555 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import mimetypes from typing import TypedDict from yarl import URL @@ -73,7 +72,7 @@ class MediaSourceOptions(TypedDict): message: str language: str | None options: dict | None - cache: bool | None + use_file_cache: bool | None @callback @@ -98,10 +97,10 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: "message": parsed.query["message"], "language": parsed.query.get("language"), "options": options, - "cache": None, + "use_file_cache": None, } if "cache" in parsed.query: - kwargs["cache"] = parsed.query["cache"] == "true" + kwargs["use_file_cache"] = parsed.query["cache"] == "true" return kwargs @@ -119,7 +118,7 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" try: - url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( + stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( **media_source_id_to_kwargs(item.identifier) ) except Unresolvable: @@ -127,9 +126,7 @@ class TTSMediaSource(MediaSource): except HomeAssistantError as err: raise Unresolvable(str(err)) from err - mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" - - return PlayMedia(url, mime_type) + return PlayMedia(stream.url, stream.content_type) async def async_browse_media( self, diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 429d46660e7..c4c1bb1ae15 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c8a639cd239..32119add5f4 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import urlsplit from tuya_sharing import ( CustomerDevice, @@ -11,6 +12,7 @@ from tuya_sharing import ( SharingDeviceListener, SharingTokenListener, ) +from tuya_sharing.mq import SharingMQ, SharingMQConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener +if TYPE_CHECKING: + import paho.mqtt.client as mqtt + + +class ManagerCompat(Manager): + """Extended Manager class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides extend refresh_mq method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def refresh_mq(self): + """Refresh the MQTT connection.""" + if self.mq is not None: + self.mq.stop() + self.mq = None + + home_ids = [home.id for home in self.user_homes] + device = [ + device + for device in self.device_map.values() + if hasattr(device, "id") and getattr(device, "set_up", False) + ] + + sharing_mq = SharingMQCompat(self.customer_api, home_ids, device) + sharing_mq.start() + sharing_mq.add_message_listener(self.on_message) + self.mq = sharing_mq + + +class SharingMQCompat(SharingMQ): + """Extended SharingMQ class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides _start method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def _start(self, mq_config: SharingMQConfig) -> mqtt.Client: + """Start the MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + mqttc = mqtt.Client(client_id=mq_config.client_id) + mqttc.username_pw_set(mq_config.username, mq_config.password) + mqttc.user_data_set({"mqConfig": mq_config}) + mqttc.on_connect = self._on_connect + mqttc.on_message = self._on_message + mqttc.on_subscribe = self._on_subscribe + mqttc.on_log = self._on_log + mqttc.on_disconnect = self._on_disconnect + + url = urlsplit(mq_config.url) + if url.scheme == "ssl": + mqttc.tls_set() + + mqttc.connect(url.hostname, url.port) + + mqttc.loop_start() + return mqttc + + async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") token_listener = TokenListener(hass, entry) - manager = Manager( + manager = ManagerCompat( TUYA_CLIENT_ID, entry.data[CONF_USER_CODE], entry.data[CONF_TERMINAL_ID], diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 56bccc73581..96f7d3a1e1c 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -53,7 +53,9 @@ ALARM: dict[str, tuple[AlarmControlPanelEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya alarm dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 12661a26fd1..486dd6e1387 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -256,7 +256,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, - on_value="alarm", + on_value={"1", "alarm"}, ), TAMPER_BINARY_SENSOR, ), @@ -291,6 +291,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( @@ -341,7 +344,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya binary sensor dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index f77fed776b0..8e538b07309 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -8,7 +8,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -58,7 +58,9 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya buttons dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 9e66531dd51..c04a8a043dc 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -8,7 +8,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -20,11 +20,16 @@ CAMERAS: tuple[str, ...] = ( # Smart Camera (including doorbells) # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sp", + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj", ) async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cameras dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 1780256a740..deccb08c5aa 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -84,7 +84,9 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya climate dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 08bdef474ef..a40260ed787 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -333,6 +333,12 @@ class DPCode(StrEnum): TEMP_CONTROLLER = "temp_controller" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_CURRENT_EXTERNAL = ( + "temp_current_external" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_F = ( + "temp_current_external_f" # Current external temperature in Fahrenheit + ) TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 9c3269c27f2..315075e7f37 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -142,7 +142,9 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cover dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index ffab9efdde8..3b951e75da1 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -34,7 +34,9 @@ TUYA_SUPPORT_TYPE = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya fan dynamically through tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index cb872d67719..6c47148eeda 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -55,7 +55,9 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya (de)humidifier dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d7dffc16b58..67a94c4e267 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import TuyaConfigEntry @@ -327,6 +327,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), + # Outdoor Flood Light + # Not documented + "tyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": ( @@ -392,6 +404,10 @@ LIGHTS["cz"] = LIGHTS["kg"] # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s LIGHTS["pc"] = LIGHTS["kg"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +LIGHTS["dghsxj"] = LIGHTS["sp"] + # Dimmer (duplicate of `tgq`) # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 LIGHTS["tdq"] = LIGHTS["tgq"] @@ -421,7 +437,9 @@ class ColorData: async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya light dynamically through tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 8d5b5dbfa19..d4fe7836daa 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType @@ -305,9 +305,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { ), } +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +NUMBERS["dghsxj"] = NUMBERS["sp"] + async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya number dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index dbc849356b2..4ad027d39ee 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -9,14 +9,16 @@ from tuya_sharing import Manager, SharingScene from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya scenes.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 831d3cb3e0c..553191b7d45 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -8,7 +8,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -326,9 +326,15 @@ SELECTS["cz"] = SELECTS["kg"] # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SELECTS["pc"] = SELECTS["kg"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SELECTS["dghsxj"] = SELECTS["sp"] + async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya select dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index f766c744998..b1150be306a 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TuyaConfigEntry @@ -45,7 +45,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): subkey: str | None = None -# Commonly used battery sensors, that are re-used in the sensors down below. +# Commonly used battery sensors, that are reused in the sensors down below. BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( TuyaSensorEntityDescription( key=DPCode.BATTERY_PERCENTAGE, @@ -715,6 +715,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( @@ -1220,9 +1261,15 @@ SENSORS["cz"] = SENSORS["kg"] # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SENSORS["pc"] = SENSORS["kg"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SENSORS["dghsxj"] = SENSORS["sp"] + async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya sensor dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 6f7dfe4c96c..039442dafe5 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -14,7 +14,7 @@ from homeassistant.components.siren import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -54,9 +54,15 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { ), } +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SIRENS["dghsxj"] = SIRENS["sp"] + async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya siren dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 8ec61cc8aa5..83847d32fb5 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -469,6 +469,9 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, + "temperature_external": { + "name": "Probe temperature" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 2b5e6fec4a6..4000e8d9b24 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -612,6 +612,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( @@ -726,9 +735,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SWITCHES["cz"] = SWITCHES["pc"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SWITCHES["dghsxj"] = SWITCHES["sp"] + async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 738492102a1..e36a682fa4e 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -48,7 +48,9 @@ TUYA_STATUS_TO_HA = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya vacuum dynamically through Tuya discovery.""" hass_data = entry.runtime_data @@ -89,9 +91,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if ( - self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) - or ( + if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or ( + ( enum_type := self.find_dpcode( DPCode.MODE, dptype=DPType.ENUM, prefer_function=True ) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index d163ae4e564..19e3f4f3337 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -7,8 +7,8 @@ from datetime import datetime, timedelta from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import WASTE_TYPE_TO_DESCRIPTION from .coordinator import TwenteMilieuConfigEntry @@ -18,7 +18,7 @@ from .entity import TwenteMilieuEntity async def async_setup_entry( hass: HomeAssistant, entry: TwenteMilieuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Twente Milieu calendar based on a config entry.""" async_add_entities([TwenteMilieuCalendar(entry)]) @@ -70,8 +70,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): waste_dates and ( next_waste_pickup_date is None - or waste_dates[0] # type: ignore[unreachable] - < next_waste_pickup_date + or waste_dates[0] < next_waste_pickup_date ) and waste_dates[0] >= dt_util.now().date() ): diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 4605ede1f87..81751d10a81 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TwenteMilieuConfigEntry @@ -65,7 +65,7 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TwenteMilieuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Twente Milieu sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index b54af031af3..7ed65bdd54b 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -8,8 +8,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py index ab79ea9692d..4c432e0aeb5 100644 --- a/homeassistant/components/twilio_call/notify.py +++ b/homeassistant/components/twilio_call/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py index 531fadcf259..a3f824f375f 100644 --- a/homeassistant/components/twilio_sms/notify.py +++ b/homeassistant/components/twilio_sms/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index cd29ffaf423..e3b53bba6c9 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -5,23 +5,19 @@ import logging from aiohttp import ClientError from ttls.client import Twinkly -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .coordinator import TwinklyCoordinator +from .coordinator import TwinklyConfigEntry, TwinklyCoordinator PLATFORMS = [Platform.LIGHT, Platform.SELECT] _LOGGER = logging.getLogger(__name__) -type TwinklyConfigEntry = ConfigEntry[TwinklyCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: """Set up entries from config flow.""" # We setup the client here so if at some point we add any other entity for this device, @@ -30,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> b client = Twinkly(host, async_get_clientsession(hass)) - coordinator = TwinklyCoordinator(hass, client) + coordinator = TwinklyCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 53ba8f084c3..0f2f87302af 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -9,10 +9,10 @@ from aiohttp import ClientError from ttls.client import Twinkly from voluptuous import Required, Schema -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN @@ -58,7 +58,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery for twinkly.""" self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py index 627fb0b39ba..2c2fc2a41d4 100644 --- a/homeassistant/components/twinkly/coordinator.py +++ b/homeassistant/components/twinkly/coordinator.py @@ -9,6 +9,7 @@ from aiohttp import ClientError from awesomeversion import AwesomeVersion from ttls.client import Twinkly, TwinklyError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,6 +18,8 @@ from .const import DEV_NAME, DOMAIN, MIN_EFFECT_VERSION _LOGGER = logging.getLogger(__name__) +type TwinklyConfigEntry = ConfigEntry[TwinklyCoordinator] + @dataclass class TwinklyData: @@ -33,15 +36,19 @@ class TwinklyData: class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]): """Class to manage fetching Twinkly data from API.""" + config_entry: TwinklyConfigEntry software_version: str supports_effects: bool device_name: str - def __init__(self, hass: HomeAssistant, client: Twinkly) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: TwinklyConfigEntry, client: Twinkly + ) -> None: """Initialize global Twinkly data updater.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py index d732ce14929..2bf46a208e8 100644 --- a/homeassistant/components/twinkly/diagnostics.py +++ b/homeassistant/components/twinkly/diagnostics.py @@ -10,8 +10,8 @@ from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, CONF_IP_ADDRESS, CON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TwinklyConfigEntry from .const import DOMAIN +from .coordinator import TwinklyConfigEntry TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] diff --git a/homeassistant/components/twinkly/icons.json b/homeassistant/components/twinkly/icons.json index 82c95aebce6..d57d54aa507 100644 --- a/homeassistant/components/twinkly/icons.json +++ b/homeassistant/components/twinkly/icons.json @@ -4,6 +4,11 @@ "light": { "default": "mdi:string-lights" } + }, + "select": { + "mode": { + "default": "mdi:cogs" + } } } } diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index de55aa5f217..c270421d8cd 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -15,10 +15,10 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TwinklyConfigEntry, TwinklyCoordinator from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW +from .coordinator import TwinklyConfigEntry, TwinklyCoordinator from .entity import TwinklyEntity _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: TwinklyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Setups an entity from a config entry (UI config flow).""" entity = TwinklyLight(config_entry.runtime_data) @@ -99,9 +99,9 @@ class TwinklyLight(TwinklyEntity, LightEntity): ): await self.client.interview() if LightEntityFeature.EFFECT & self.supported_features: - # Static color only supports rgb await self.client.set_static_colour( ( + kwargs[ATTR_RGBW_COLOR][3], kwargs[ATTR_RGBW_COLOR][0], kwargs[ATTR_RGBW_COLOR][1], kwargs[ATTR_RGBW_COLOR][2], diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 2542d325b47..a5283b3f91d 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -8,9 +8,9 @@ from ttls.client import TWINKLY_MODES from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import TwinklyConfigEntry, TwinklyCoordinator +from .coordinator import TwinklyConfigEntry, TwinklyCoordinator from .entity import TwinklyEntity _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: TwinklyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a mode select from a config entry.""" entity = TwinklyModeSelect(config_entry.runtime_data) @@ -29,13 +29,13 @@ async def async_setup_entry( class TwinklyModeSelect(TwinklyEntity, SelectEntity): """Twinkly Mode Selection.""" - _attr_name = "Mode" + _attr_translation_key = "mode" _attr_options = TWINKLY_MODES def __init__(self, coordinator: TwinklyCoordinator) -> None: """Initialize TwinklyModeSelect.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data.device_info["mac"]}_mode" + self._attr_unique_id = f"{coordinator.data.device_info['mac']}_mode" self.client = coordinator.client @property diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index bbc3d67373d..c2e0efef92c 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -20,5 +20,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "select": { + "mode": { + "name": "Mode", + "state": { + "color": "Color", + "demo": "Demo", + "effect": "Effect", + "movie": "Uploaded effect", + "off": "[%key:common::state::off%]", + "playlist": "Playlist", + "rt": "Real time" + } + } + } } } diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index c61e80bd2b8..010a9e90ccc 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -122,7 +122,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): stream.game_name if stream else None, stream.title if stream else None, stream.started_at if stream else None, - stream.thumbnail_url if stream else None, + stream.thumbnail_url.format(width="", height="") if stream else None, channel.profile_image_url, bool(sub), sub.is_gift if sub else None, diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index b407eae0319..deec319e5cf 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -32,7 +32,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: TwitchConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize entries.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py index eef51ca9613..f94bcd54459 100644 --- a/homeassistant/components/twitter/notify.py +++ b/homeassistant/components/twitter/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ublockout/__init__.py b/homeassistant/components/ublockout/__init__.py new file mode 100644 index 00000000000..87127e331da --- /dev/null +++ b/homeassistant/components/ublockout/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ublockout.""" diff --git a/homeassistant/components/ublockout/manifest.json b/homeassistant/components/ublockout/manifest.json new file mode 100644 index 00000000000..d5ef46b8ad2 --- /dev/null +++ b/homeassistant/components/ublockout/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ublockout", + "name": "Ublockout", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 285a176af0a..7c50b69683f 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index a86f7a1cc83..594d46c74ab 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MODE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -33,6 +32,7 @@ ATTR_NEXT_BUSES = "next_buses" ATTR_STATION_CODE = "station_code" ATTR_CALLING_AT = "calling_at" ATTR_NEXT_TRAINS = "next_trains" +ATTR_LAST_UPDATED = "last_updated" CONF_API_APP_KEY = "app_key" CONF_API_APP_ID = "app_id" @@ -200,7 +200,9 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_NEXT_BUSES: self._next_buses} + attrs = { + ATTR_NEXT_BUSES: self._next_buses, + } for key in ( ATTR_ATCOCODE, ATTR_LOCALITY, @@ -273,6 +275,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor): attrs = { ATTR_STATION_CODE: self._station_code, ATTR_CALLING_AT: self._calling_at, + ATTR_LAST_UPDATED: self._data[ATTR_REQUEST_TIME], } if self._next_trains: attrs[ATTR_NEXT_TRAINS] = self._next_trains diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index d850ed6eba8..3658b821625 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -13,11 +12,9 @@ from .coordinator import UkraineAlarmDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ukraine Alarm as config entry.""" - region_id = entry.data[CONF_REGION] - websession = async_get_clientsession(hass) - coordinator = UkraineAlarmDataUpdateCoordinator(hass, websession, region_id) + coordinator = UkraineAlarmDataUpdateCoordinator(hass, entry, websession) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index 30cb8e0f553..9009031ea14 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -64,7 +64,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ukraine Alarm binary sensor entities based on a config entry.""" name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index fbf7c9f81c2..267358e4aa6 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -10,6 +10,8 @@ import aiohttp from aiohttp import ClientSession from uasiren.client import Client +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,17 +25,25 @@ UPDATE_INTERVAL = timedelta(seconds=10) class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Ukraine Alarm API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, session: ClientSession, - region_id: str, ) -> None: """Initialize.""" - self.region_id = region_id + self.region_id = config_entry.data[CONF_REGION] self.uasiren = Client(session) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 25c6816d794..3e5ef62f49e 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -31,7 +31,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .entity import ( @@ -135,7 +135,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 63c8533aa2e..3878e4c60eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -18,7 +18,6 @@ from urllib.parse import urlparse from aiounifi.interfaces.sites import Sites import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntryState, @@ -34,8 +33,13 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import UnifiConfigEntry from .const import ( @@ -212,12 +216,12 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): return await self.async_step_user() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" parsed_url = urlparse(discovery_info.ssdp_location) - model_description = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_DESCRIPTION] - mac_address = format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) + model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION] + mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]) self.config = { CONF_HOST: parsed_url.hostname, diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 735f76a73bf..a26232664a8 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -16,7 +16,7 @@ from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey -from propcache import cached_property +from propcache.api import cached_property from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, @@ -24,10 +24,10 @@ from homeassistant.components.device_tracker import ( ScannerEntityDescription, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .const import DOMAIN as UNIFI_DOMAIN @@ -222,7 +222,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" async_update_unique_id(hass, config_entry) diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py index 782b026d6e4..b353ba6fc5c 100644 --- a/homeassistant/components/unifi/hub/entity_helper.py +++ b/homeassistant/components/unifi/hub/entity_helper.py @@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceSetPoePortModeRequest from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util class UnifiEntityHelper: diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index f11ddefec98..84948a92e98 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -46,10 +46,15 @@ class UnifiEntityLoader: hub.api.port_forwarding.update, hub.api.sites.update, hub.api.system_information.update, + hub.api.firewall_policies.update, hub.api.traffic_rules.update, + hub.api.traffic_routes.update, hub.api.wlans.update, ) - self.polling_api_updaters = (hub.api.traffic_rules.update,) + self.polling_api_updaters = ( + hub.api.traffic_rules.update, + hub.api.traffic_routes.update, + ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] self._dataUpdateCoordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 76990c1c4a1..616d7cb185f 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -55,12 +55,18 @@ "off": "mdi:network-off" } }, + "firewall_policy_control": { + "default": "mdi:security-network" + }, "port_forward_control": { "default": "mdi:upload-network" }, "traffic_rule_control": { "default": "mdi:security-network" }, + "traffic_route_control": { + "default": "mdi:routes" + }, "poe_port_control": { "default": "mdi:ethernet", "state": { diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 1f54f56b194..f3045d5fc1c 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -16,8 +16,8 @@ from aiounifi.models.wlan import Wlan from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .entity import ( @@ -67,7 +67,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index ce573592153..dd255c57c13 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==81"], + "requirements": ["aiounifi==83"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 194a8575174..47a2c2ba62e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -46,10 +46,9 @@ from homeassistant.const import ( ) from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES @@ -645,7 +644,7 @@ ENTITY_DESCRIPTIONS += make_wan_latency_sensors() + make_device_temperatur_senso async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index ce726a0f5d0..9d4d92839fc 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -6,7 +6,6 @@ from typing import Any from aiounifi.models.client import ClientReconnectRequest, ClientRemoveRequest import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr @@ -67,10 +66,9 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - if mac == "": return - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if config_entry.state is not ConfigEntryState.LOADED or ( - (hub := config_entry.runtime_data) - and not hub.available + for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + if ( + (not (hub := config_entry.runtime_data).available) or (client := hub.api.clients.get(mac)) is None or client.is_wired ): @@ -86,12 +84,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Total time between first seen and last seen is less than 15 minutes. - Neither IP, hostname nor name is configured. """ - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if ( - config_entry.state is not ConfigEntryState.LOADED - or (hub := config_entry.runtime_data) - and not hub.available - ): + for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + if not (hub := config_entry.runtime_data).available: continue clients_to_remove = [] diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 01843a8a95b..282d0c9ae93 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -4,6 +4,7 @@ Support for controlling power supply of clients which are powered over Ethernet Support for controlling network access of clients selected in option flow. Support for controlling deep packet inspection (DPI) restriction groups. Support for controlling WLAN availability. +Support for controlling zone based traffic rules. """ from __future__ import annotations @@ -17,9 +18,11 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups +from aiounifi.interfaces.firewall_policies import FirewallPolicies from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.traffic_routes import TrafficRoutes from aiounifi.interfaces.traffic_rules import TrafficRules from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT @@ -28,9 +31,11 @@ from aiounifi.models.device import DeviceSetOutletRelayRequest from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey +from aiounifi.models.firewall_policy import FirewallPolicy, FirewallPolicyUpdateRequest from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest +from aiounifi.models.traffic_route import TrafficRoute, TrafficRouteSaveRequest from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest @@ -42,9 +47,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN @@ -127,6 +132,24 @@ async def async_dpi_group_control_fn(hub: UnifiHub, obj_id: str, target: bool) - ) +async def async_firewall_policy_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control firewall policy state.""" + policy = hub.api.firewall_policies[obj_id].raw + policy["enabled"] = target + await hub.api.request(FirewallPolicyUpdateRequest.create(policy)) + # Update the policies so the UI is updated appropriately + await hub.api.firewall_policies.update() + + +@callback +def async_firewall_policy_supported_fn(hub: UnifiHub, obj_id: str) -> bool: + """Check if firewall policy is able to be controlled. Predefined policies are unable to be turned off.""" + policy = hub.api.firewall_policies[obj_id] + return not policy.predefined + + @callback def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet supports switching.""" @@ -170,6 +193,16 @@ async def async_traffic_rule_control_fn( await hub.api.traffic_rules.update() +async def async_traffic_route_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control traffic route state.""" + traffic_route = hub.api.traffic_routes[obj_id].raw + await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target)) + # Update the traffic routes so the UI is updated appropriately + await hub.api.traffic_routes.update() + + async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" await hub.api.request(WlanEnableRequest.create(obj_id, target)) @@ -224,6 +257,20 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( supported_fn=lambda hub, obj_id: bool(hub.api.dpi_groups[obj_id].dpiapp_ids), unique_id_fn=lambda hub, obj_id: obj_id, ), + UnifiSwitchEntityDescription[FirewallPolicies, FirewallPolicy]( + key="Firewall policy control", + translation_key="firewall_policy_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.firewall_policies, + control_fn=async_firewall_policy_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, firewall_policy: firewall_policy.enabled, + name_fn=lambda firewall_policy: firewall_policy.name, + object_fn=lambda api, obj_id: api.firewall_policies[obj_id], + unique_id_fn=lambda hub, obj_id: f"firewall_policy-{obj_id}", + supported_fn=async_firewall_policy_supported_fn, + ), UnifiSwitchEntityDescription[Outlets, Outlet]( key="Outlet control", device_class=SwitchDeviceClass.OUTLET, @@ -263,6 +310,19 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( object_fn=lambda api, obj_id: api.traffic_rules[obj_id], unique_id_fn=lambda hub, obj_id: f"traffic_rule-{obj_id}", ), + UnifiSwitchEntityDescription[TrafficRoutes, TrafficRoute]( + key="Traffic route control", + translation_key="traffic_route_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.traffic_routes, + control_fn=async_traffic_route_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, traffic_route: traffic_route.enabled, + name_fn=lambda traffic_route: traffic_route.description, + object_fn=lambda api, obj_id: api.traffic_routes[obj_id], + unique_id_fn=lambda hub, obj_id: f"traffic_route-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", translation_key="poe_port_control", @@ -327,7 +387,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" async_update_unique_id(hass, config_entry) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 65202045a05..589b2ff1215 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -19,7 +19,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .entity import ( @@ -68,7 +68,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index d5e2e926114..1d7511aaae8 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiled/light.py b/homeassistant/components/unifiled/light.py index 4e1981875f4..dbc73177f21 100644 --- a/homeassistant/components/unifiled/light.py +++ b/homeassistant/components/unifiled/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index a88d4b65678..0d904d3c3ba 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -769,7 +769,7 @@ def _async_nvr_entities( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index b24c90be3ec..7b766299946 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -19,7 +19,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICES_THAT_ADOPT, DOMAIN from .data import ProtectDeviceType, UFPConfigEntry @@ -120,7 +120,7 @@ def _async_remove_adopt_button( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover devices on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 0b1c03b8dd6..3947324fd73 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -16,7 +16,7 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity from .const import ( @@ -138,7 +138,7 @@ def _async_camera_entities( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover cameras on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 31950f8f7e4..22af2fb135d 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -14,7 +14,6 @@ from uiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol -from homeassistant.components import dhcp, ssdp from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -36,6 +35,8 @@ from homeassistant.helpers.aiohttp_client import ( async_create_clientsession, async_get_clientsession, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -107,14 +108,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): self._discovered_device: dict[str, str] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) return await self._async_discovery_handoff() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UniFi device.""" _LOGGER.debug("Starting discovery via: %s", discovery_info) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 335bc1e933d..90804559297 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,7 @@ from uiprotect.data import ( ) from homeassistant.core import callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index c8bce183e34..cb9090dd530 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -10,7 +10,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Bootstrap from .const import ( @@ -181,7 +181,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="nfc", translation_key="nfc", - device_class=EventDeviceClass.DOORBELL, icon="mdi:nfc", ufp_required_field="feature_flags.support_nfc", ufp_event_obj="last_nfc_card_scanned_event", @@ -191,7 +190,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = ( ProtectEventEntityDescription( key="fingerprint", translation_key="fingerprint", - device_class=EventDeviceClass.DOORBELL, icon="mdi:fingerprint", ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_event_obj="last_fingerprint_identified_event", @@ -220,7 +218,7 @@ def _async_event_entities( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up event entities for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index fcdfe5e85b8..873f715de58 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -9,7 +9,7 @@ from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 3e9372db0e5..79ed47a6c3b 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -14,7 +14,7 @@ from uiprotect.data import ( from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 018a600f037..a4bb6d20841 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.4.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 5f9991b257b..a1e60931026 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity @@ -36,7 +36,7 @@ _SPEAKER_DESCRIPTION = MediaPlayerEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover cameras with speakers on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 767128337ba..5dbf9f2b00e 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -17,7 +17,7 @@ from uiprotect.data import ( from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -227,7 +227,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 00c277c957e..054c9430387 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -29,7 +29,7 @@ from uiprotect.data import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import TYPE_EMPTY_VALUE from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -334,7 +334,9 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( - hass: HomeAssistant, entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 09187e023a1..a719f36c2b3 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -38,7 +38,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -640,7 +640,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index cde8c88d169..d5a7d615399 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -3,8 +3,8 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "UniFi Protect Setup", - "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect setup", + "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -17,17 +17,17 @@ } }, "reauth_confirm": { - "title": "UniFi Protect Reauth", + "title": "UniFi Protect reauth", "data": { - "host": "IP/Host of UniFi Protect Server", + "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } }, "discovery_confirm": { - "title": "UniFi Protect Discovered", - "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect discovered", + "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -38,7 +38,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", - "cloud_user": "Ubiquiti Cloud users are not Supported. Please use a Local only user." + "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -49,12 +49,12 @@ "options": { "step": { "init": { - "title": "UniFi Protect Options", + "title": "UniFi Protect options", "description": "Realtime metrics option should only be enabled if you have enabled the diagnostics sensors and want them updated in realtime. If not enabled, they will only update once every 15 minutes.", "data": { "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", - "override_connection_host": "Override Connection Host", + "override_connection_host": "Override connection host", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" } @@ -68,7 +68,7 @@ "step": { "start": { "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." + "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." }, "confirm": { "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", @@ -123,8 +123,8 @@ } }, "deprecate_hdr_switch": { - "title": "HDR Mode Switch Deprecated", - "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode Switch has been replaced with an HDR Mode Select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." + "title": "HDR Mode switch deprecated", + "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode switch has been replaced with an HDR Mode select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." } }, "entity": { @@ -171,22 +171,22 @@ }, "services": { "add_doorbell_text": { - "name": "Add custom doorbell text", + "name": "Add doorbell text", "description": "Adds a new custom message for doorbells.", "fields": { "device_id": { "name": "UniFi Protect NVR", - "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances." + "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect instances." }, "message": { "name": "Custom message", - "description": "New custom message to add for doorbells. Must be less than 30 characters." + "description": "New custom message to add. Must be less than 30 characters." } } }, "remove_doorbell_text": { - "name": "Remove custom doorbell text", - "description": "Removes an existing message for doorbells.", + "name": "Remove doorbell text", + "description": "Removes an existing custom message for doorbells.", "fields": { "device_id": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", @@ -194,13 +194,13 @@ }, "message": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::message::name%]", - "description": "Existing custom message to remove for doorbells." + "description": "Existing custom message to remove." } } }, "set_chime_paired_doorbells": { "name": "Set chime paired doorbells", - "description": "Use to set the paired doorbell(s) with a smart chime.", + "description": "Pairs doorbell(s) with a smart chime.", "fields": { "device_id": { "name": "Chime", @@ -213,22 +213,22 @@ } }, "remove_privacy_zone": { - "name": "Remove camera privacy zone", - "description": "Use to remove a privacy zone from a camera.", + "name": "Remove privacy zone", + "description": "Removes a privacy zone from a camera.", "fields": { "device_id": { "name": "Camera", - "description": "Camera you want to remove privacy zone from." + "description": "Camera you want to remove the privacy zone from." }, "name": { - "name": "Privacy Zone Name", + "name": "Privacy zone", "description": "The name of the zone to remove." } } }, "get_user_keyring_info": { - "name": "Retrieve Keyring Details for Users", - "description": "Fetch a detailed list of users with NFC and fingerprint associations for automations.", + "name": "Get user keyring info", + "description": "Fetches a detailed list of users with NFC and fingerprint associations for automations.", "fields": { "device_id": { "name": "UniFi Protect NVR", diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index fa960261cf2..fce92912a52 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -18,7 +18,7 @@ from uiprotect.data import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -568,7 +568,7 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 0c7e1322f23..1c468d44cc6 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -15,7 +15,7 @@ from uiprotect.data import ( from homeassistant.components.text import TextEntity, TextEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -63,7 +63,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index c9f3a2df105..ebfc8eaeece 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b file = config_entry.data[CONF_FILE_PATH] upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file}) + await upb.load_upstart_file() await upb.async_connect() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config_entry.entry_id] = {"upb": upb} diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 788a0336d73..af1ee7d5ab0 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -40,8 +40,9 @@ async def _validate_input(data): url = _make_url_from_data(data) upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file_path}) - - await upb.async_connect(_connected_callback) + upb.add_handler("connected", _connected_callback) + await upb.load_upstart_file() + await upb.async_connect() if not upb.config_ok: _LOGGER.error("Missing or invalid UPB file: %s", file_path) diff --git a/homeassistant/components/upb/const.py b/homeassistant/components/upb/const.py index 16f2f1b7923..6e063c5a088 100644 --- a/homeassistant/components/upb/const.py +++ b/homeassistant/components/upb/const.py @@ -2,7 +2,7 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType DOMAIN = "upb" diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py index 13037adf680..8a9afa453b1 100644 --- a/homeassistant/components/upb/entity.py +++ b/homeassistant/components/upb/entity.py @@ -30,7 +30,7 @@ class UpbEntity(Entity): return self._element.as_dict() @property - def available(self): + def available(self) -> bool: """Is the entity available to be updated.""" return self._upb.is_connected() @@ -43,7 +43,7 @@ class UpbEntity(Entity): self._element_changed(element, changeset) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callback for UPB changes and update entity state.""" self._element.add_callback(self._element_callback) self._element_callback(self._element, {}) diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 07bd50b7d9f..0838ec3ef01 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -13,7 +13,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA from .entity import UpbAttachedEntity @@ -26,7 +26,7 @@ SERVICE_LIGHT_BLINK = "light_blink" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPB light based on a config entry.""" diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 1e61747b3f1..e5da4c4d621 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.9"] + "requirements": ["upb-lib==0.6.0"] } diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py index 5a5e17b3e4c..45a1d664b15 100644 --- a/homeassistant/components/upb/scene.py +++ b/homeassistant/components/upb/scene.py @@ -6,7 +6,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA from .entity import UpbEntity @@ -21,7 +21,7 @@ SERVICE_LINK_BLINK = "link_blink" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPB link based on a config entry.""" upb = hass.data[DOMAIN][config_entry.entry_id]["upb"] diff --git a/homeassistant/components/upb/services.yaml b/homeassistant/components/upb/services.yaml index cf415705d72..985ce11c436 100644 --- a/homeassistant/components/upb/services.yaml +++ b/homeassistant/components/upb/services.yaml @@ -49,7 +49,7 @@ link_deactivate: target: entity: integration: upb - domain: light + domain: scene link_goto: target: diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index c279be78666..bdaf01518f1 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -15,8 +15,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 30d7cacba8e..a3fec73dca8 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -import dataclasses from datetime import timedelta import logging import requests.exceptions import upcloud_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,34 +21,21 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) -from .const import ( - CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, - DATA_UPCLOUD, - DEFAULT_SCAN_INTERVAL, -) -from .coordinator import UpCloudDataUpdateCoordinator +from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] -@dataclasses.dataclass -class UpCloudHassData: - """Home Assistant UpCloud runtime data.""" - - coordinators: dict[str, UpCloudDataUpdateCoordinator] = dataclasses.field( - default_factory=dict - ) - - -def _config_entry_update_signal_name(config_entry: ConfigEntry) -> str: +def _config_entry_update_signal_name(config_entry: UpCloudConfigEntry) -> str: """Get signal name for updates to a config entry.""" return CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE.format(config_entry.unique_id) async def _async_signal_options_update( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UpCloudConfigEntry ) -> None: """Signal config entry options update.""" async_dispatcher_send( @@ -58,7 +43,7 @@ async def _async_signal_options_update( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Set up the UpCloud config entry.""" manager = upcloud_api.CloudManager( @@ -81,10 +66,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = UpCloudDataUpdateCoordinator( hass, + config_entry=entry, update_interval=update_interval, cloud_manager=manager, username=entry.data[CONF_USERNAME], ) + entry.runtime_data = coordinator # Call the UpCloud API to refresh data await coordinator.async_config_entry_first_refresh() @@ -99,21 +86,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data[DATA_UPCLOUD] = UpCloudHassData() - hass.data[DATA_UPCLOUD].coordinators[entry.data[CONF_USERNAME]] = coordinator - # Forward entry setup await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpCloudConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DATA_UPCLOUD].coordinators.pop(config_entry.data[CONF_USERNAME]) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index f135eea24b1..923d8f2d896 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -4,23 +4,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_UPCLOUD +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: UpCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UpCloud server binary sensor.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudBinarySensor(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudBinarySensor(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index bb988726ba5..16adcc51ddf 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -9,16 +9,12 @@ import requests.exceptions import upcloud_api import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import UpCloudConfigEntry _LOGGER = logging.getLogger(__name__) @@ -92,7 +88,7 @@ class UpCloudConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: UpCloudConfigEntry, ) -> UpCloudOptionsFlow: """Get options flow.""" return UpCloudOptionsFlow() diff --git a/homeassistant/components/upcloud/const.py b/homeassistant/components/upcloud/const.py index a967a43c46e..763462c37f4 100644 --- a/homeassistant/components/upcloud/const.py +++ b/homeassistant/components/upcloud/const.py @@ -3,6 +3,5 @@ from datetime import timedelta DOMAIN = "upcloud" -DATA_UPCLOUD = "data_upcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE = f"{DOMAIN}_config_entry_update:{{}}" diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py index e10128a30e4..8088b3a72ea 100644 --- a/homeassistant/components/upcloud/coordinator.py +++ b/homeassistant/components/upcloud/coordinator.py @@ -15,6 +15,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type UpCloudConfigEntry = ConfigEntry[UpCloudDataUpdateCoordinator] + + class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[dict[str, upcloud_api.Server]] ): @@ -24,17 +27,22 @@ class UpCloudDataUpdateCoordinator( self, hass: HomeAssistant, *, + config_entry: UpCloudConfigEntry, cloud_manager: upcloud_api.CloudManager, update_interval: timedelta, username: str, ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval + hass, + _LOGGER, + config_entry=config_entry, + name=f"{username}@UpCloud", + update_interval=update_interval, ) self.cloud_manager = cloud_manager - async def async_update_config(self, config_entry: ConfigEntry) -> None: + async def async_update_config(self, config_entry: UpCloudConfigEntry) -> None: """Handle config update.""" self.update_interval = timedelta( seconds=config_entry.options[CONF_SCAN_INTERVAL] diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py index c64ca7be2ea..1ff5374bcaf 100644 --- a/homeassistant/components/upcloud/entity.py +++ b/homeassistant/components/upcloud/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any import upcloud_api @@ -12,9 +11,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import UpCloudDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import UpCloudConfigEntry, UpCloudDataUpdateCoordinator ATTR_CORE_NUMBER = "core_number" ATTR_HOSTNAME = "hostname" @@ -33,11 +30,12 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): def __init__( self, - coordinator: UpCloudDataUpdateCoordinator, + config_entry: UpCloudConfigEntry, uuid: str, ) -> None: """Initialize the UpCloud server entity.""" - super().__init__(coordinator) + super().__init__(config_entry.runtime_data) + self.config_entry = config_entry self.uuid = uuid @property @@ -95,13 +93,11 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - assert self.coordinator.config_entry is not None + assert self.config_entry is not None return DeviceInfo( configuration_url="https://hub.upcloud.com", model="Control Panel", entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") - }, + identifiers={(DOMAIN, f"{self.config_entry.data[CONF_USERNAME]}@hub")}, manufacturer="UpCloud Ltd", ) diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 7495357ca9e..de180907919 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -3,13 +3,12 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, STATE_OFF +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_UPCLOUD +from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity SIGNAL_UPDATE_UPCLOUD = "upcloud_update" @@ -17,12 +16,12 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + config_entry: UpCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UpCloud server switch.""" - coordinator = hass.data[DATA_UPCLOUD].coordinators[config_entry.data[CONF_USERNAME]] - entities = [UpCloudSwitch(coordinator, uuid) for uuid in coordinator.data] + coordinator = config_entry.runtime_data + entities = [UpCloudSwitch(config_entry, uuid) for uuid in coordinator.data] async_add_entities(entities, True) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 8ef9f44237f..0ff8c448197 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -9,7 +9,7 @@ import logging from typing import Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components import websocket_api @@ -68,8 +68,8 @@ __all__ = [ "ATTR_VERSION", "DEVICE_CLASSES_SCHEMA", "DOMAIN", - "PLATFORM_SCHEMA_BASE", "PLATFORM_SCHEMA", + "PLATFORM_SCHEMA_BASE", "SERVICE_INSTALL", "SERVICE_SKIP", "UpdateDeviceClass", diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml index 036af10150a..45e30fee09e 100644 --- a/homeassistant/components/update/services.yaml +++ b/homeassistant/components/update/services.yaml @@ -9,6 +9,9 @@ install: selector: text: backup: + filter: + supported_features: + - update.UpdateEntityFeature.BACKUP required: false selector: boolean: diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 214521ee9c0..757cad221b5 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -8,11 +8,11 @@ from datetime import timedelta from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -27,7 +27,7 @@ from .const import ( IDENTIFIER_SERIAL_NUMBER, LOGGER, ) -from .coordinator import UpnpDataUpdateCoordinator +from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator from .device import async_create_device, get_preferred_location NOTIFICATION_ID = "upnp_notification" @@ -36,9 +36,6 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.entry_id) @@ -49,10 +46,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool # Register device discovered-callback. device_discovered_event = asyncio.Event() - discovery_info: ssdp.SsdpServiceInfo | None = None + discovery_info: SsdpServiceInfo | None = None async def device_discovered( - headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange + headers: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: if change == ssdp.SsdpChange.BYEBYE: return @@ -175,6 +172,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) coordinator = UpnpDataUpdateCoordinator( hass, + config_entry=entry, device=device, device_entry=device_entry, update_interval=update_interval, @@ -192,7 +190,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index fb32946bf7d..0c7b7aa5dc2 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -11,10 +11,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import UpnpConfigEntry, UpnpDataUpdateCoordinator from .const import LOGGER, WAN_STATUS +from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator from .entity import UpnpEntity, UpnpEntityDescription @@ -38,7 +38,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UpnpConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 41e481fa58c..95fd1ff0ea5 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -9,7 +9,6 @@ from urllib.parse import urlparse import voluptuous as vol from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigEntry, @@ -18,6 +17,12 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + SsdpServiceInfo, +) from .const import ( CONFIG_ENTRY_FORCE_POLL, @@ -37,17 +42,17 @@ from .const import ( from .device import async_get_mac_address_from_host, get_preferred_location -def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str: +def _friendly_name_from_discovery(discovery_info: SsdpServiceInfo) -> str: """Extract user-friendly name from discovery.""" return cast( str, - discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) - or discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or discovery_info.ssdp_headers.get("_host", ""), ) -def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_complete_discovery(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is complete and usable.""" return bool( discovery_info.ssdp_udn @@ -59,7 +64,7 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: async def _async_discovered_igd_devices( hass: HomeAssistant, -) -> list[ssdp.SsdpServiceInfo]: +) -> list[SsdpServiceInfo]: """Discovery IGD devices.""" return await ssdp.async_get_discovery_info_by_st( hass, ST_IGD_V1 @@ -76,10 +81,10 @@ async def _async_mac_address_from_discovery( return await async_get_mac_address_from_host(hass, host) -def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: +def _is_igd_device(discovery_info: SsdpServiceInfo) -> bool: """Test if discovery is a complete IGD device.""" root_device_info = discovery_info.upnp - return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} + return root_device_info.get(ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): @@ -167,7 +172,7 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered UPnP/IGD device. diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py index 37ff700bfe2..03e4c53f143 100644 --- a/homeassistant/components/upnp/coordinator.py +++ b/homeassistant/components/upnp/coordinator.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from async_upnp_client.exceptions import UpnpCommunicationError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER from .device import Device +type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] + class UpnpDataUpdateCoordinator( DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] ): """Define an object to update data from UPNP device.""" + config_entry: UpnpConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: UpnpConfigEntry, device: Device, device_entry: DeviceEntry, update_interval: timedelta, @@ -34,6 +40,7 @@ class UpnpDataUpdateCoordinator( super().__init__( hass, LOGGER, + config_entry=config_entry, name=device.name, update_interval=update_interval, ) diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 08e0be2d712..df4daa8782c 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index aae2f8308c1..c7e343d36b5 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -18,9 +18,8 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import UpnpConfigEntry from .const import ( BYTES_RECEIVED, BYTES_SENT, @@ -38,6 +37,7 @@ from .const import ( ROUTER_UPTIME, WAN_STATUS, ) +from .coordinator import UpnpConfigEntry from .entity import UpnpEntity, UpnpEntityDescription @@ -153,7 +153,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UpnpConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 266542de9d6..488682a79c6 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -6,8 +6,8 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -15,7 +15,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" async_add_entities([UptimeSensor(entry)]) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index afff0c8fe03..b8619b1fe39 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS @@ -24,12 +23,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Wrong API key type detected, use the 'main' API key" ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) - dev_reg = dr.async_get(hass) hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( hass, - config_entry_id=entry.entry_id, - dev_reg=dev_reg, + entry, api=uptime_robot_api, ) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 0c1bd972387..73f9400c013 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import UptimeRobotDataUpdateCoordinator @@ -19,7 +19,7 @@ from .entity import UptimeRobotEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot binary_sensors.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 3069884eb99..fbadc237965 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -26,19 +26,18 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon def __init__( self, hass: HomeAssistant, - config_entry_id: str, - dev_reg: dr.DeviceRegistry, + config_entry: ConfigEntry, api: UptimeRobot, ) -> None: """Initialize coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=COORDINATOR_UPDATE_INTERVAL, ) - self._config_entry_id = config_entry_id - self._device_registry = dev_reg + self._device_registry = dr.async_get(hass) self.api = api async def _async_update_data(self) -> list[UptimeRobotMonitor]: @@ -58,7 +57,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon current_monitors = { list(device.identifiers)[0][1] for device in dr.async_entries_for_config_entry( - self._device_registry, self._config_entry_id + self._device_registry, self.config_entry.entry_id ) } new_monitors = {str(monitor.id) for monitor in monitors} @@ -73,7 +72,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon # create new devices and entities. if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry_id) + self.hass.config_entries.async_reload(self.config_entry.entry_id) ) return monitors diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index c5ff8abf5d9..724c3075a3b 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import UptimeRobotDataUpdateCoordinator @@ -28,7 +28,7 @@ SENSORS_INFO = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot sensors.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index aa7d07e10fd..31401ac7eb4 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_ATTR_OK, DOMAIN, LOGGER from .coordinator import UptimeRobotDataUpdateCoordinator @@ -21,7 +21,9 @@ from .entity import UptimeRobotEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot switches.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 4517501bf43..d68742522a0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations -from collections.abc import Coroutine, Sequence +import asyncio +from collections.abc import Callable, Coroutine, Sequence import dataclasses +from datetime import datetime, timedelta import fnmatch +from functools import partial import logging import os import sys -from typing import TYPE_CHECKING, Any, overload +from typing import Any, overload +from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol @@ -24,9 +28,16 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import config_validation as cv, discovery_flow, system_info +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -34,18 +45,19 @@ from .const import DOMAIN from .models import USBDevice from .utils import usb_device_from_port -if TYPE_CHECKING: - from pyudev import Device, MonitorObserver - _LOGGER = logging.getLogger(__name__) -REQUEST_SCAN_COOLDOWN = 60 # 1 minute cooldown +PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] + +POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) +REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown +ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register __all__ = [ - "async_is_plugged_in", - "async_register_scan_request_callback", "USBCallbackMatcher", - "UsbServiceInfo", + "async_is_plugged_in", + "async_register_port_event_callback", + "async_register_scan_request_callback", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -76,6 +88,15 @@ def async_register_initial_scan_callback( return discovery.async_register_initial_scan_callback(callback) +@hass_callback +def async_register_port_event_callback( + hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE +) -> CALLBACK_TYPE: + """Register to receive a callback when a USB device is connected or disconnected.""" + discovery: USBDiscovery = hass.data[DOMAIN] + return discovery.async_register_port_event_callback(callback) + + @hass_callback def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool: """Return True is a USB device is present.""" @@ -99,21 +120,33 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo usb_discovery: USBDiscovery = hass.data[DOMAIN] return any( - _is_matching(USBDevice(*device_tuple), matcher) - for device_tuple in usb_discovery.seen + _is_matching( + USBDevice( + device=device, + vid=vid, + pid=pid, + serial_number=serial_number, + manufacturer=manufacturer, + description=description, + ), + matcher, + ) + for ( + device, + vid, + pid, + serial_number, + manufacturer, + description, + ) in usb_discovery.seen ) -@dataclasses.dataclass(slots=True) -class UsbServiceInfo(BaseServiceInfo): - """Prepared info from usb entries.""" - - device: str - vid: str - pid: str - serial_number: str | None - manufacturer: str | None - description: str | None +_DEPRECATED_UsbServiceInfo = DeprecatedConstant( + _UsbServiceInfo, + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + "2026.2", +) @overload @@ -222,13 +255,19 @@ class USBDiscovery: self.seen: set[tuple[str, ...]] = set() self.observer_active = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None + self._add_remove_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] + self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() + self._last_processed_devices: set[USBDevice] = set() + self._scan_lock = asyncio.Lock() async def async_setup(self) -> None: """Set up USB Discovery.""" - await self._async_start_monitor() + if self._async_supports_monitoring(): + await self._async_start_monitor() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -242,69 +281,60 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() + @hass_callback + def _async_supports_monitoring(self) -> bool: + return sys.platform == "linux" + async def _async_start_monitor(self) -> None: - """Start monitoring hardware with pyudev.""" - if not sys.platform.startswith("linux"): - return - info = await system_info.async_get_system_info(self.hass) - if info.get("docker"): - return - - if not ( - observer := await self.hass.async_add_executor_job( - self._get_monitor_observer + """Start monitoring hardware.""" + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: + _LOGGER.info( + "Falling back to periodic filesystem polling for development, aiousbwatcher " + "is not available on this system: %s", + ex, ) - ): - return + self._async_start_monitor_polling() - def _stop_observer(event: Event) -> None: - observer.stop() + @hass_callback + def _async_start_monitor_polling(self) -> None: + """Start monitoring hardware with polling (for development only!).""" - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) - self.observer_active = True + async def _scan(event_time: datetime) -> None: + await self._async_scan_serial() - def _get_monitor_observer(self) -> MonitorObserver | None: - """Get the monitor observer. + stop_callback = async_track_time_interval( + self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD + ) - This runs in the executor because the import - does blocking I/O. + @hass_callback + def _stop_polling(event: Event) -> None: + stop_callback() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) + + async def _async_start_aiousbwatcher(self) -> None: + """Start monitoring hardware with aiousbwatcher. + + Returns True if successful. """ - from pyudev import ( # pylint: disable=import-outside-toplevel - Context, - Monitor, - MonitorObserver, - ) - try: - context = Context() - except (ImportError, OSError): - return None + @hass_callback + def _usb_change_callback() -> None: + self._async_delayed_add_remove_scan() - monitor = Monitor.from_netlink(context) - try: - monitor.filter_by(subsystem="tty") - except ValueError as ex: # this fails on WSL - _LOGGER.debug( - "Unable to setup pyudev filtering; This is expected on WSL: %s", ex - ) - return None + watcher = AIOUSBWatcher() + watcher.async_register_callback(_usb_change_callback) + cancel = watcher.async_start() - observer = MonitorObserver( - monitor, callback=self._device_discovered, name="usb-observer" - ) + @hass_callback + def _async_stop_watcher(event: Event) -> None: + cancel() - observer.start() - return observer + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_watcher) - def _device_discovered(self, device: Device) -> None: - """Call when the observer discovers a new usb tty device.""" - if device.action != "add": - return - _LOGGER.debug( - "Discovered Device at path: %s, triggering scan serial", - device.device_path, - ) - self.hass.create_task(self._async_scan()) + self.observer_active = True @hass_callback def async_register_scan_request_callback( @@ -340,6 +370,20 @@ class USBDiscovery: return _async_remove_callback + @hass_callback + def async_register_port_event_callback( + self, + callback: PORT_EVENT_CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register a port event callback.""" + self._port_event_callbacks.add(callback) + + @hass_callback + def _async_remove_callback() -> None: + self._port_event_callbacks.discard(callback) + + return _async_remove_callback + async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" _LOGGER.debug("Discovered USB Device: %s", device) @@ -352,7 +396,7 @@ class USBDiscovery: if not matched: return - service_info: UsbServiceInfo | None = None + service_info: _UsbServiceInfo | None = None sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) most_matched_fields = len(sorted_by_most_targeted[0]) @@ -364,7 +408,7 @@ class USBDiscovery: break if service_info is None: - service_info = UsbServiceInfo( + service_info = _UsbServiceInfo( device=await self.hass.async_add_executor_job( get_serial_by_id, device.device ), @@ -384,11 +428,13 @@ class USBDiscovery: async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" - usb_devices = [ + _LOGGER.debug("Processing ports: %r", ports) + usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None - ] + } + _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -399,7 +445,7 @@ class USBDiscovery: if dev.device.startswith("/dev/cu.SLAB_USBtoUART") } - usb_devices = [ + usb_devices = { dev for dev in usb_devices if dev.serial_number not in silabs_serials @@ -407,16 +453,47 @@ class USBDiscovery: dev.serial_number in silabs_serials and dev.device.startswith("/dev/cu.SLAB_USBtoUART") ) - ] + } + + added_devices = usb_devices - self._last_processed_devices + removed_devices = self._last_processed_devices - usb_devices + self._last_processed_devices = usb_devices + + _LOGGER.debug( + "Added devices: %r, removed devices: %r", added_devices, removed_devices + ) + + if added_devices or removed_devices: + for callback in self._port_event_callbacks.copy(): + try: + callback(added_devices, removed_devices) + except Exception: + _LOGGER.exception("Error in USB port event callback") for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) + @hass_callback + def _async_delayed_add_remove_scan(self) -> None: + """Request a serial scan after a debouncer delay.""" + if not self._add_remove_debouncer: + self._add_remove_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=ADD_REMOVE_SCAN_COOLDOWN, + immediate=False, + function=self._async_scan, + background=True, + ) + self._add_remove_debouncer.async_schedule_call() + async def _async_scan_serial(self) -> None: """Scan serial ports.""" - await self._async_process_ports( - await self.hass.async_add_executor_job(comports) - ) + _LOGGER.debug("Executing comports scan") + async with self._scan_lock: + await self._async_process_ports( + await self.hass.async_add_executor_job(comports) + ) if self.initial_scan_done: return @@ -457,3 +534,11 @@ async def websocket_usb_scan( if not usb_discovery.observer_active: await usb_discovery.async_request_scan() connection.send_result(msg["id"]) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 19269801c11..7035e2ab2cb 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pyudev==0.24.1", "pyserial==3.5"] + "requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"] } diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index efc5b11c26e..11eccd9cd9b 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -@dataclass +@dataclass(slots=True, frozen=True, kw_only=True) class USBDevice: """A usb device.""" diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index aa9817eab7d..3dd380e79a8 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -27,8 +27,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index aac31e085a0..e2b3411c193 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -11,8 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 5815ce7ec95..0c818525c8d 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -10,7 +10,10 @@ from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Utility Meter config entry.""" name = config_entry.title diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 9c13aa1984a..425dfa2c3fd 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -41,7 +41,10 @@ from homeassistant.core import ( from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, @@ -49,8 +52,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import ( @@ -117,7 +119,7 @@ def validate_is_number(value): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Utility Meter config entry.""" entry_id = config_entry.entry_id diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index a6f0202ee25..0e09408551d 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -20,7 +20,7 @@ from homeassistant.components.camera import ( from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 0c07891df72..7cd5e71f3ae 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from pytrydan import Trydan -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -19,15 +18,11 @@ PLATFORMS: list[Platform] = [ ] -type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Set up V2C from a config entry.""" - host = entry.data[CONF_HOST] - trydan = Trydan(host, get_async_client(hass, verify_ssl=False)) - coordinator = V2CUpdateCoordinator(hass, trydan, host) + trydan = Trydan(entry.data[CONF_HOST], get_async_client(hass, verify_ssl=False)) + coordinator = V2CUpdateCoordinator(hass, entry, trydan) await coordinator.async_config_entry_first_refresh() @@ -41,6 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 28ad3665996..85f03d6b4fb 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -13,10 +13,9 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -51,7 +50,7 @@ TRYDAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C binary sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py index b121c84563c..de8015985f9 100644 --- a/homeassistant/components/v2c/coordinator.py +++ b/homeassistant/components/v2c/coordinator.py @@ -9,6 +9,7 @@ from pytrydan import Trydan, TrydanData from pytrydan.exceptions import TrydanError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,19 +17,24 @@ SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] + class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): """DataUpdateCoordinator to gather data from any v2c.""" - config_entry: ConfigEntry + config_entry: V2CConfigEntry - def __init__(self, hass: HomeAssistant, evse: Trydan, host: str) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: V2CConfigEntry, evse: Trydan + ) -> None: """Initialize DataUpdateCoordinator for a v2c evse.""" self.evse = evse super().__init__( hass, _LOGGER, - name=f"EVSE {host}", + config_entry=config_entry, + name=f"EVSE {config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/v2c/diagnostics.py b/homeassistant/components/v2c/diagnostics.py index 289d585b164..994f702a7bd 100644 --- a/homeassistant/components/v2c/diagnostics.py +++ b/homeassistant/components/v2c/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import V2CConfigEntry +from .coordinator import V2CConfigEntry TO_REDACT = {CONF_HOST, "title"} diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 1540b098cf1..e52242f0ce0 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -15,10 +15,9 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfElectricCurrent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity MIN_INTENSITY = 6 @@ -72,7 +71,7 @@ TRYDAN_NUMBER_SETTINGS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C Trydan number platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 97853740e9d..cfccaacda18 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -23,11 +23,10 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) @@ -143,7 +142,7 @@ TRYDAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index cca7da70e48..20bc3419757 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -18,10 +18,9 @@ from pytrydan.models.trydan import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) @@ -80,7 +79,7 @@ TRYDAN_SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C switch platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 6fe2c3e2a5b..3b1eee8509c 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -9,7 +9,7 @@ from functools import partial import logging from typing import TYPE_CHECKING, Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -376,7 +376,7 @@ class StateVacuumEntity( Remove this compatibility shim in 2025.1 or later. """ features = self.supported_features - if type(features) is int: # noqa: E721 + if type(features) is int: new_features = VacuumEntityFeature(features) self._report_deprecated_supported_features_values(new_features) return new_features diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index 82c00a57b5e..0ae03d9219e 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index ceb34bc6ff9..785ecd09fb1 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -111,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = Vallox(host) - coordinator = ValloxDataUpdateCoordinator(hass, name, client) + coordinator = ValloxDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 4a0efc7b101..a205dd2039e 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -62,7 +62,7 @@ BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" diff --git a/homeassistant/components/vallox/coordinator.py b/homeassistant/components/vallox/coordinator.py index c2485c7b4fd..2fe7fa533db 100644 --- a/homeassistant/components/vallox/coordinator.py +++ b/homeassistant/components/vallox/coordinator.py @@ -6,6 +6,8 @@ import logging from vallox_websocket_api import MetricData, Vallox, ValloxApiException +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,17 +19,20 @@ _LOGGER = logging.getLogger(__name__) class ValloxDataUpdateCoordinator(DataUpdateCoordinator[MetricData]): """The DataUpdateCoordinator for Vallox.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - name: str, + config_entry: ConfigEntry, client: Vallox, ) -> None: """Initialize Vallox data coordinator.""" super().__init__( hass, _LOGGER, - name=f"{name} DataUpdateCoordinator", + config_entry=config_entry, + name=f"{config_entry.data[CONF_NAME]} DataUpdateCoordinator", update_interval=STATE_SCAN_INTERVAL, ) self.client = client diff --git a/homeassistant/components/vallox/date.py b/homeassistant/components/vallox/date.py index 33c3ebb253c..da2906c02c2 100644 --- a/homeassistant/components/vallox/date.py +++ b/homeassistant/components/vallox/date.py @@ -10,7 +10,7 @@ from homeassistant.components.date import DateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -51,7 +51,7 @@ class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity): async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vallox filter change date entity.""" diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 3a21ef060a7..8519b4cb913 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -57,7 +57,9 @@ def _convert_to_int(value: StateType) -> int | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fan device.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index 96bc07b5a93..ce3b9c72a6d 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -102,7 +102,9 @@ NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 7165947861a..e9194a8254c 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -278,7 +278,9 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" name = hass.data[DOMAIN][entry.entry_id]["name"] diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 8a30ed4ad01..f00206826d3 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -110,7 +110,7 @@ "fields": { "fan_speed": { "name": "Fan speed", - "description": "Fan speed." + "description": "Relative speed of the built-in fans." } } }, @@ -119,7 +119,7 @@ "description": "Sets the fan speed of the Away profile.", "fields": { "fan_speed": { - "name": "Fan speed", + "name": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::name%]", "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" } } @@ -129,7 +129,7 @@ "description": "Sets the fan speed of the Boost profile.", "fields": { "fan_speed": { - "name": "Fan speed", + "name": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::name%]", "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" } } diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 20b270f8f18..9386f914f58 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -82,7 +82,7 @@ SWITCH_ENTITIES: tuple[ValloxSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches.""" diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 48f659103e1..424ffdc0ed2 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DELAY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index ad1c35a124b..35c61892964 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -58,6 +58,21 @@ async def velbus_scan_task( raise PlatformNotReady( f"Connection error while connecting to Velbus {entry_id}: {ex}" ) from ex + # create all modules + dev_reg = dr.async_get(hass) + for module in controller.get_modules().values(): + dev_reg.async_get_or_create( + config_entry_id=entry_id, + identifiers={ + (DOMAIN, str(module.get_addresses()[0])), + }, + manufacturer="Velleman", + model=module.get_type_name(), + model_id=str(module.get_type()), + name=f"{module.get_name()} ({module.get_type_name()})", + sw_version=module.get_sw_version(), + serial_number=module.get_serial(), + ) def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: @@ -120,15 +135,39 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: VelbusConfigEntry ) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/") - if config_entry.version == 1: - # This is the config entry migration for adding the new program selection + _LOGGER.error( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + # This is the config entry migration for adding the new program selection + # migrate from 1.x to 2.1 + if config_entry.version < 2: # clean the velbusCache + cache_path = hass.config.path( + STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/" + ) if os.path.isdir(cache_path): await hass.async_add_executor_job(shutil.rmtree, cache_path) - # set the new version - hass.config_entries.async_update_entry(config_entry, version=2) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + # This is the config entry migration for swapping the usb unique id to the serial number + # migrate from 2.1 to 2.2 + if ( + config_entry.version < 3 + and config_entry.minor_version == 1 + and config_entry.unique_id is not None + ): + # not all velbus devices have a unique id, so handle this correctly + parts = config_entry.unique_id.split("_") + # old one should have 4 item + if len(parts) == 4: + hass.config_entries.async_update_entry(config_entry, unique_id=parts[1]) + + # update the config entry + hass.config_entries.async_update_entry(config_entry, version=2, minor_version=2) + + _LOGGER.error( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 88dc994efe8..2ddf6605c19 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -4,7 +4,7 @@ from velbusaio.channels import Button as VelbusButton from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity @@ -15,7 +15,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index fc943159123..8f736dcd35b 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -10,7 +10,7 @@ from velbusaio.channels import ( from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index b2f3077ecee..e31d9a97416 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .const import DOMAIN, PRESET_MODES @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 26e2fafabbc..fc5da92588a 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -4,22 +4,23 @@ from __future__ import annotations from typing import Any +import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol -from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PORT -from homeassistant.util import slugify +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import DOMAIN +from .const import CONF_TLS, DOMAIN class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the velbus config flow.""" @@ -27,14 +28,16 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device: str = "" self._title: str = "" - def _create_device(self, name: str, prt: str) -> ConfigFlowResult: + def _create_device(self) -> ConfigFlowResult: """Create an entry async.""" - return self.async_create_entry(title=name, data={CONF_PORT: prt}) + return self.async_create_entry( + title=self._title, data={CONF_PORT: self._device} + ) - async def _test_connection(self, prt: str) -> bool: + async def _test_connection(self) -> bool: """Try to connect to the velbus with the port specified.""" try: - controller = velbusaio.controller.Velbus(prt) + controller = velbusaio.controller.Velbus(self._device) await controller.connect() await controller.stop() except VelbusConnectionFailed: @@ -46,45 +49,86 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Step when user initializes a integration.""" - self._errors = {} + return self.async_show_menu( + step_id="user", menu_options=["network", "usbselect"] + ) + + async def async_step_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle network step.""" if user_input is not None: - name = slugify(user_input[CONF_NAME]) - prt = user_input[CONF_PORT] - self._async_abort_entries_match({CONF_PORT: prt}) - if await self._test_connection(prt): - return self._create_device(name, prt) + self._title = "Velbus Network" + if user_input[CONF_TLS]: + self._device = "tls://" + else: + self._device = "" + if user_input[CONF_PASSWORD] != "": + self._device += f"{user_input[CONF_PASSWORD]}@" + self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() else: - user_input = {} - user_input[CONF_NAME] = "" - user_input[CONF_PORT] = "" + user_input = { + CONF_TLS: True, + CONF_PORT: 27015, + } return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, - vol.Required(CONF_PORT, default=user_input[CONF_PORT]): str, - } + step_id="network", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TLS): bool, + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + suggested_values=user_input, ), errors=self._errors, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo + async def async_step_usbselect( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle USB Discovery.""" - await self.async_set_unique_id( - f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" + """Handle usb select step.""" + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = [ + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + + if user_input is not None: + self._title = "Velbus USB" + self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() + else: + user_input = {} + user_input[CONF_PORT] = "" + + return self.async_show_form( + step_id="usbselect", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}), + suggested_values=user_input, + ), + errors=self._errors, ) - dev_path = discovery_info.device - # check if this device is not already configured - self._async_abort_entries_match({CONF_PORT: dev_path}) - # check if we can make a valid velbus connection - if not await self._test_connection(dev_path): - return self.async_abort(reason="cannot_connect") - # store the data for the config step - self._device = dev_path + + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: + """Handle USB Discovery.""" + await self.async_set_unique_id(discovery_info.serial_number) + self._device = discovery_info.device self._title = "Velbus USB" + self._async_abort_entries_match({CONF_PORT: self._device}) + if not await self._test_connection(): + return self.async_abort(reason="cannot_connect") # call the config step self._set_confirm_only() return await self.async_step_discovery_confirm() @@ -94,7 +138,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Discovery confirmation.""" if user_input is not None: - return self._create_device(self._title, self._device) + return self._create_device() return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index 2d9f6e98a4c..f42e449bdcc 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -11,8 +11,10 @@ from homeassistant.components.climate import ( DOMAIN: Final = "velbus" +CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" +CONF_TLS: Final = "tls" SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 2ddea37f2d6..995b7e9d59c 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 65f8a1d8d31..07dac78b6f1 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -14,32 +14,57 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN +# device identifiers for modules +# (DOMAIN, module_address) + +# device identifiers for channels that are subdevices of a module +# (DOMAIN, f"{module_address}-{channel_number}") + class VelbusEntity(Entity): """Representation of a Velbus entity.""" + _attr_has_entity_name = True _attr_should_poll: bool = False def __init__(self, channel: VelbusChannel) -> None: """Initialize a Velbus entity.""" self._channel = channel + self._module_adress = str(channel.get_module_address()) self._attr_name = channel.get_name() self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, str(channel.get_module_address())), + (DOMAIN, self._get_identifier()), }, manufacturer="Velleman", model=channel.get_module_type_name(), + model_id=str(channel.get_module_type()), name=channel.get_full_name(), sw_version=channel.get_module_sw_version(), + serial_number=channel.get_module_serial(), ) - serial = channel.get_module_serial() or str(channel.get_module_address()) + if self._channel.is_sub_device(): + self._attr_device_info["via_device"] = ( + DOMAIN, + self._module_adress, + ) + serial = channel.get_module_serial() or self._module_adress self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" + def _get_identifier(self) -> str: + """Return the identifier of the entity.""" + if not self._channel.is_sub_device(): + return self._module_adress + return f"{self._module_adress}-{self._channel.get_channel_number()}" + async def async_added_to_hass(self) -> None: """Add listener for state changes.""" self._channel.on_status_update(self._on_update) + async def async_will_remove_from_hass(self) -> None: + """Remove listener for state changes.""" + self._channel.remove_on_status_update(self._on_update) + async def _on_update(self) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index 1adf52a8198..5037e2b1ced 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -23,7 +23,7 @@ from homeassistant.components.light import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task @@ -122,19 +122,14 @@ class VelbusButtonLight(VelbusEntity, LightEntity): @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" - if ATTR_FLASH in kwargs: - if kwargs[ATTR_FLASH] == FLASH_LONG: - attr, *args = "set_led_state", "slow" - elif kwargs[ATTR_FLASH] == FLASH_SHORT: - attr, *args = "set_led_state", "fast" - else: - attr, *args = "set_led_state", "on" + if (flash := ATTR_FLASH in kwargs) and kwargs[ATTR_FLASH] == FLASH_LONG: + await self._channel.set_led_state("slow") + elif flash and kwargs[ATTR_FLASH] == FLASH_SHORT: + await self._channel.set_led_state("fast") else: - attr, *args = "set_led_state", "on" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("on") @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" - attr, *args = "set_led_state", "off" - await getattr(self._channel, attr)(*args) + await self._channel.set_led_state("off") diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 94c823888b7..29504277651 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,8 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.12.3"], + "quality_scale": "bronze", + "requirements": ["velbus-aio==2025.1.1"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 477b6768e71..829f48e6f52 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -8,25 +8,19 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: todo + entity-event-setup: done entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done - unique-config-entry: - status: todo - comment: | - Manual step does not generate an unique-id + unique-config-entry: done # Silver action-exceptions: todo @@ -41,7 +35,7 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index 6c2dfe0a3b1..1d52b8d4afc 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -5,7 +5,7 @@ from velbusaio.channels import SelectedProgram from homeassistant.components.select import SelectEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -16,7 +16,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus select based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 77833da3ee1..96ef91e8174 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 3f0b1bd6cdb..765c5a0f674 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -9,15 +9,19 @@ from typing import TYPE_CHECKING import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.storage import STORAGE_DIR if TYPE_CHECKING: from . import VelbusConfigEntry from .const import ( + CONF_CONFIG_ENTRY, CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, @@ -32,6 +36,7 @@ def setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: + """Check the config_entry for a specific interface.""" for config_entry in hass.config_entries.async_entries(DOMAIN): if "port" in config_entry.data and config_entry.data["port"] == interface: return config_entry.entry_id @@ -39,51 +44,71 @@ def setup_services(hass: HomeAssistant) -> None: "The interface provided is not defined as a port in a Velbus integration" ) - def get_config_entry(interface: str) -> VelbusConfigEntry | None: - for config_entry in hass.config_entries.async_entries(DOMAIN): - if "port" in config_entry.data and config_entry.data["port"] == interface: - return config_entry - return None + async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: + """Get the config entry for this service call.""" + if CONF_CONFIG_ENTRY in call.data: + entry_id = call.data[CONF_CONFIG_ENTRY] + elif CONF_INTERFACE in call.data: + # Deprecated in 2025.2, to remove in 2025.8 + async_create_issue( + hass, + DOMAIN, + "deprecated_interface_parameter", + breaks_in_ha_version="2025.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_interface_parameter", + ) + entry_id = call.data[CONF_INTERFACE] + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry async def scan(call: ServiceCall) -> None: """Handle a scan service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.scan() + entry = await get_config_entry(call) + await entry.runtime_data.controller.scan() async def syn_clock(call: ServiceCall) -> None: """Handle a sync clock service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - await entry.runtime_data.controller.sync_clock() + entry = await get_config_entry(call) + await entry.runtime_data.controller.sync_clock() async def set_memo_text(call: ServiceCall) -> None: """Handle Memo Text service call.""" - entry = get_config_entry(call.data[CONF_INTERFACE]) - if entry: - memo_text = call.data[CONF_MEMO_TEXT] - module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) - if module: - await module.set_memo_text(memo_text.async_render()) + entry = await get_config_entry(call) + memo_text = call.data[CONF_MEMO_TEXT] + module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + if not module: + raise ServiceValidationError("Module not found") + await module.set_memo_text(memo_text.async_render()) async def clear_cache(call: ServiceCall) -> None: """Handle a clear cache service call.""" - # clear the cache + entry = await get_config_entry(call) with suppress(FileNotFoundError): if call.data.get(CONF_ADDRESS): await hass.async_add_executor_job( os.unlink, hass.config.path( STORAGE_DIR, - f"velbuscache-{call.data[CONF_INTERFACE]}/{call.data[CONF_ADDRESS]}.p", + f"velbuscache-{entry.entry_id}/{call.data[CONF_ADDRESS]}.p", ), ) else: await hass.async_add_executor_job( shutil.rmtree, - hass.config.path( - STORAGE_DIR, f"velbuscache-{call.data[CONF_INTERFACE]}/" - ), + hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), ) # call a scan to repopulate await scan(call) @@ -92,28 +117,73 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SCAN, scan, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SYNC, syn_clock, - vol.Schema({vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id)}), + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } + ), + ), ) hass.services.async_register( DOMAIN, SERVICE_SET_MEMO_TEXT, set_memo_text, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } + ), ), ) @@ -121,12 +191,26 @@ def setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_CLEAR_CACHE, clear_cache, - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } + vol.Any( + vol.Schema( + { + vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } + ), ), ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index e3ecc3556f0..39886913692 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,29 +1,38 @@ sync_clock: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus scan: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus clear_cache: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: false selector: @@ -34,11 +43,14 @@ clear_cache: set_memo_text: fields: interface: - required: true example: "192.168.1.5:27015" default: "" selector: text: + config_entry: + selector: + config_entry: + integration: velbus address: required: true selector: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index be1d992056e..a50395af115 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,11 +2,37 @@ "config": { "step": { "user": { - "title": "Define the velbus connection type", + "title": "Define the Velbus connection type", "data": { - "name": "The name for this velbus connection", + "name": "The name for this Velbus connection", "port": "Connection string" } + }, + "network": { + "title": "TCP/IP configuration", + "data": { + "tls": "Use TLS (secure connection)", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.", + "host": "The IP address or hostname of the Velbus interface.", + "port": "The port number of the Velbus interface.", + "password": "The password of the Velbus interface, this is only needed if the interface is password protected." + }, + "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface." + }, + "usbselect": { + "title": "USB configuration", + "data": { + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "Select the serial port for your Velbus USB interface." + }, + "description": "Select the serial port for your Velbus USB interface." } }, "error": { @@ -20,37 +46,55 @@ "exceptions": { "invalid_hvac_mode": { "message": "Climate mode {hvac_mode} is not supported." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } }, "services": { "sync_clock": { "name": "Sync clock", - "description": "Syncs the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { "interface": { "name": "Interface", - "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + "description": "The Velbus interface to send the command to, this will be the same value as used during configuration." + }, + "config_entry": { + "name": "Config entry", + "description": "The config entry of the Velbus integration" } } }, "scan": { "name": "Scan", - "description": "Scans the velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", + "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" } } }, "clear_cache": { "name": "Clear cache", - "description": "Clears the velbuscache and then starts a new scan.", + "description": "Clears the Velbus cache and then starts a new scan.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page." @@ -65,6 +109,10 @@ "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" }, + "config_entry": { + "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" + }, "address": { "name": "Address", "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page." @@ -75,5 +123,11 @@ } } } + }, + "issues": { + "deprecated_interface_parameter": { + "title": "Deprecated 'interface' parameter", + "description": "The 'interface' parameter in the Velbus actions is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + } } } diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 8256e716d4f..40dc3c09f73 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -6,7 +6,7 @@ from velbusaio.channels import Relay as VelbusRelay from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -17,7 +17,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index f4bfa13b4d5..24f65aa3b0b 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Velux integration.""" +from typing import Any + from pyvlx import PyVLX, PyVLXException import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, LOGGER -DATA_SCHEMA = vol.Schema( +USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -17,9 +21,31 @@ DATA_SCHEMA = vol.Schema( ) +async def _check_connection(host: str, password: str) -> dict[str, Any]: + """Check if we can connect to the Velux bridge.""" + pyvlx = PyVLX(host=host, password=password) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + LOGGER.debug("Cannot connect: %s", err) + return {"base": "cannot_connect"} + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected exception: %s", err) + return {"base": "unknown"} + + return {} + + class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for velux.""" + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.discovery_data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -28,28 +54,78 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - - pyvlx = PyVLX( - host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + errors = await _check_connection( + user_input[CONF_HOST], user_input[CONF_PASSWORD] ) - try: - await pyvlx.connect() - await pyvlx.disconnect() - except (PyVLXException, ConnectionError) as err: - errors["base"] = "cannot_connect" - LOGGER.debug("Cannot connect: %s", err) - except Exception as err: # noqa: BLE001 - LOGGER.exception("Unexpected exception: %s", err) - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry( title=user_input[CONF_HOST], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) return self.async_show_form( step_id="user", - data_schema=data_schema, + data_schema=USER_SCHEMA, errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery by DHCP.""" + # The hostname ends with the last 4 digits of the device MAC address. + self.discovery_data[CONF_HOST] = discovery_info.ip + self.discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress) + self.discovery_data[CONF_NAME] = discovery_info.hostname.upper().replace( + "LAN_", "" + ) + + await self.async_set_unique_id(self.discovery_data[CONF_NAME]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.discovery_data[CONF_HOST]} + ) + + # Abort if config_entry already exists without unigue_id configured. + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == self.discovery_data[CONF_HOST] + and entry.unique_id is None + and entry.state is ConfigEntryState.LOADED + ): + self.hass.config_entries.async_update_entry( + entry=entry, + unique_id=self.discovery_data[CONF_NAME], + data={**entry.data, **self.discovery_data}, + ) + return self.async_abort(reason="already_configured") + self._async_abort_entries_match({CONF_HOST: self.discovery_data[CONF_HOST]}) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare configuration for a discovered Velux device.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await _check_connection( + self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=self.discovery_data[CONF_NAME], + data={**self.discovery_data, **user_input}, + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + description_placeholders={ + "name": self.discovery_data[CONF_NAME], + "host": self.discovery_data[CONF_HOST], + }, + ) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 90745f601b4..d6bf8905d91 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import VeluxEntity @@ -25,7 +25,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover(s) for Velux platform.""" module = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 674ba5dde45..1231a98e0a8 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -31,6 +31,6 @@ class VeluxEntity(Entity): self.node.register_device_updated_cb(after_update_callback) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Store register state change callback.""" self.async_register_callbacks() diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index 14f12a01060..b991239b7a4 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -9,7 +9,7 @@ from pyvlx import Intensity, LighteningDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import VeluxEntity @@ -18,7 +18,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light(s) for Velux platform.""" module = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 053b7fcc594..cb21fef299d 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,8 +1,14 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum"], + "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"], "config_flow": true, + "dhcp": [ + { + "hostname": "velux_klf*", + "macaddress": "646184*" + } + ], "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 54888413613..636ab82e819 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -15,7 +15,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the scenes for Velux platform.""" module = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 5b7b459a3f7..0cf578732fb 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -2,11 +2,16 @@ "config": { "step": { "user": { - "title": "Setup Velux", "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "discovery_confirm": { + "description": "Please enter the password for {name} ({host})", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 3243c7a6f47..faa47bfc8e4 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -21,14 +21,14 @@ from .coordinator import VenstarDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Venstar thermostat.""" - username = config.data.get(CONF_USERNAME) - password = config.data.get(CONF_PASSWORD) - pin = config.data.get(CONF_PIN) - host = config.data[CONF_HOST] + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + pin = config_entry.data.get(CONF_PIN) + host = config_entry.data[CONF_HOST] timeout = VENSTAR_TIMEOUT - protocol = "https" if config.data[CONF_SSL] else "http" + protocol = "https" if config_entry.data[CONF_SSL] else "http" client = VenstarColorTouch( addr=host, @@ -41,19 +41,22 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: venstar_data_coordinator = VenstarDataUpdateCoordinator( hass, + config_entry, venstar_connection=client, ) await venstar_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config.entry_id] = venstar_data_coordinator - await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = venstar_data_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload the config and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) if unload_ok: - hass.data[DOMAIN].pop(config.entry_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py index 315df09b625..672db463791 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import VenstarEntity @@ -15,7 +15,7 @@ from .entity import VenstarEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vensar device binary_sensors based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index c5323e1e9a8..ade86e8dd71 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -32,8 +32,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -66,7 +69,7 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Venstar thermostat.""" venstar_data_coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/venstar/coordinator.py b/homeassistant/components/venstar/coordinator.py index b825775de7f..1d0ff60c1e0 100644 --- a/homeassistant/components/venstar/coordinator.py +++ b/homeassistant/components/venstar/coordinator.py @@ -8,6 +8,7 @@ from datetime import timedelta from requests import RequestException from venstarcolortouch import VenstarColorTouch +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator @@ -17,16 +18,19 @@ from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): """Class to manage fetching Venstar data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, + config_entry: ConfigEntry, venstar_connection: VenstarColorTouch, ) -> None: """Initialize global Venstar data updater.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 94180f6ad79..14e7103a83f 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import VenstarDataUpdateCoordinator @@ -81,7 +81,7 @@ class VenstarSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Venstar device sensors based on a config entry.""" coordinator: VenstarDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 3438ee81d4a..00780fec8ce 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySenso from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -17,7 +17,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index eb2a5206f30..084725f484e 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -17,7 +17,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -30,7 +30,7 @@ SUPPORT_HVAC = [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index b5b57f43c0c..8256804b8a3 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import ATTR_POSITION, ENTITY_ID_FORMAT, Cove from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -19,7 +19,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index 84e21e54983..b3013c288c1 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -52,7 +52,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): """Update the state.""" self.schedule_update_ha_state(True) - def update(self): + def update(self) -> None: """Force a refresh from the device if the device is unavailable.""" refresh_needed = self.vera_device.should_poll or not self.available _LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed) @@ -90,7 +90,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): return attr @property - def available(self): + def available(self) -> bool: """If device communications have failed return false.""" return not self.vera_device.comm_failure diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index e512676de9a..f573fcd94ea 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -16,8 +16,8 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -26,7 +26,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 18f0b9de3e2..3f76f3a6106 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -22,7 +22,7 @@ ATTR_LOW_BATTERY = "low_battery" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index 22061f98929..0e504b12303 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -9,7 +9,7 @@ import pyvera as veraApi from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from .common import ControllerData, get_controller_data @@ -19,7 +19,7 @@ from .const import VERA_ID_FORMAT async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 95f1fa0bd89..d778b4c2e5d 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -32,7 +32,7 @@ SCAN_INTERVAL = timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index ad7fbe68458..67be4a7849a 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -19,7 +19,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5f34b587163..7ead1f014c8 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ALARM_STATE_TO_HA, CONF_GIID, DOMAIN, LOGGER @@ -23,7 +23,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" async_add_entities([VerisureAlarm(coordinator=hass.data[DOMAIN][entry.entry_id])]) @@ -49,14 +49,14 @@ class VerisureAlarm( name="Verisure Alarm", manufacturer="Verisure", model="VBox", - identifiers={(DOMAIN, self.coordinator.entry.data[CONF_GIID])}, + identifiers={(DOMAIN, self.coordinator.config_entry.data[CONF_GIID])}, configuration_url="https://mypages.verisure.com", ) @property def unique_id(self) -> str: """Return the unique ID for this entity.""" - return self.coordinator.entry.data[CONF_GIID] + return self.coordinator.config_entry.data[CONF_GIID] async def _async_set_arm_state( self, state: str, command_data: dict[str, str | dict[str, str]] diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 542ee3485ce..4d9221c3ca9 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -22,7 +22,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure binary sensors based on a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -62,7 +62,7 @@ class VerisureDoorWindowSensor( manufacturer="Verisure", model="Shock Sensor Detector", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -104,7 +104,7 @@ class VerisureEthernetStatus( @property def unique_id(self) -> str: """Return the unique ID for this entity.""" - return f"{self.coordinator.entry.data[CONF_GIID]}_ethernet" + return f"{self.coordinator.config_entry.data[CONF_GIID]}_ethernet" @property def device_info(self) -> DeviceInfo: @@ -113,7 +113,7 @@ class VerisureEthernetStatus( name="Verisure Alarm", manufacturer="Verisure", model="VBox", - identifiers={(DOMAIN, self.coordinator.entry.data[CONF_GIID])}, + identifiers={(DOMAIN, self.coordinator.config_entry.data[CONF_GIID])}, configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 70cd436d24c..1f5d48ea197 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -13,7 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure sensors based on a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -75,7 +75,7 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) manufacturer="Verisure", model="SmartCam", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 930d862257b..5165ddc6d3d 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -25,10 +25,11 @@ from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER class VerisureDataUpdateCoordinator(DataUpdateCoordinator): """A Verisure Data Update Coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" self.imageseries: list[dict[str, str]] = [] - self.entry = entry self._overview: list[dict] = [] self.verisure = Verisure( @@ -40,7 +41,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): ) super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) async def async_login(self) -> bool: @@ -55,7 +60,7 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return False await self.hass.async_add_executor_job( - self.verisure.set_giid, self.entry.data[CONF_GIID] + self.verisure.set_giid, self.config_entry.data[CONF_GIID] ) return True diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 87f5c53880e..76aeedd05fa 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -33,7 +33,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -81,7 +81,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt manufacturer="Verisure", model="Lockguard Smartlock", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -109,7 +109,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def code_format(self) -> str: """Return the configured code format.""" - digits = self.coordinator.entry.options.get( + digits = self.coordinator.config_entry.options.get( CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS ) return f"^\\d{{{digits}}}$" diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 4f6e6b3d3c5..6ed4784bffb 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DEVICE_TYPE_NAME, DOMAIN @@ -22,7 +22,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure sensors based on a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -72,7 +72,7 @@ class VerisureThermometer( manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -122,7 +122,7 @@ class VerisureHygrometer( manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index e0238097e01..0deb1da5e95 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DOMAIN @@ -19,7 +19,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -57,7 +57,7 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch manufacturer="Verisure", model="SmartPlug", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py index ed4a8edf32c..cbd69ba0a81 100644 --- a/homeassistant/components/versasense/__init__.py +++ b/homeassistant/components/versasense/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index cf13821dc8a..6fabf97c8dd 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -6,7 +6,6 @@ import logging from pyhaversion import HaVersion -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -18,12 +17,10 @@ from .const import ( CONF_SOURCE, PLATFORMS, ) -from .coordinator import VersionDataUpdateCoordinator +from .coordinator import VersionConfigEntry, VersionDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -type VersionConfigEntry = ConfigEntry[VersionDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: VersionConfigEntry) -> bool: """Set up the version integration from a config entry.""" @@ -40,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VersionConfigEntry) -> b coordinator = VersionDataUpdateCoordinator( hass=hass, + config_entry=entry, api=HaVersion( session=async_get_clientsession(hass), source=entry.data[CONF_SOURCE], diff --git a/homeassistant/components/version/binary_sensor.py b/homeassistant/components/version/binary_sensor.py index 827029e1d8c..900daa7aba1 100644 --- a/homeassistant/components/version/binary_sensor.py +++ b/homeassistant/components/version/binary_sensor.py @@ -11,10 +11,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME, EntityCategory, __version__ as HA_VERSION from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import VersionConfigEntry from .const import CONF_SOURCE, DEFAULT_NAME +from .coordinator import VersionConfigEntry from .entity import VersionEntity HA_VERSION_OBJECT = AwesomeVersion(HA_VERSION) @@ -23,7 +23,7 @@ HA_VERSION_OBJECT = AwesomeVersion(HA_VERSION) async def async_setup_entry( hass: HomeAssistant, config_entry: VersionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up version binary_sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/version/coordinator.py b/homeassistant/components/version/coordinator.py index 05adf07642b..349ede53d33 100644 --- a/homeassistant/components/version/coordinator.py +++ b/homeassistant/components/version/coordinator.py @@ -14,21 +14,25 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, UPDATE_COORDINATOR_UPDATE_INTERVAL +type VersionConfigEntry = ConfigEntry[VersionDataUpdateCoordinator] + class VersionDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for Version entities.""" - config_entry: ConfigEntry + config_entry: VersionConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: VersionConfigEntry, api: HaVersion, ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=UPDATE_COORDINATOR_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index ca7318f468b..bcc94bd8da4 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -9,7 +9,7 @@ from attr import asdict from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import VersionConfigEntry +from .coordinator import VersionConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index e1d552bcd36..7e173b46d36 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -7,18 +7,18 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import VersionConfigEntry from .const import CONF_SOURCE, DEFAULT_NAME +from .coordinator import VersionConfigEntry from .entity import VersionEntity async def async_setup_entry( hass: HomeAssistant, entry: VersionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up version sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 8e8b7744988..dddf7857545 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -5,25 +5,39 @@ import logging from pyvesync import VeSync from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + EVENT_LOGGING_CHANGED, + Platform, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -from .common import async_process_devices +from .common import async_generate_device_list from .const import ( DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, - VS_LIGHTS, + VS_LISTENERS, VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, ) from .coordinator import VeSyncDataCoordinator -PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) @@ -35,103 +49,57 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b time_zone = str(hass.config.time_zone) - manager = VeSync(username, password, time_zone) + manager = VeSync( + username=username, + password=password, + time_zone=time_zone, + debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG, + redact=True, + ) login = await hass.async_add_executor_job(manager.login) if not login: - _LOGGER.error("Unable to login to the VeSync server") - return False - - device_dict = await async_process_devices(hass, manager) - - forward_setups = hass.config_entries.async_forward_entry_setups + raise ConfigEntryAuthFailed hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager - coordinator = VeSyncDataCoordinator(hass, manager) + coordinator = VeSyncDataCoordinator(hass, config_entry, manager) # Store coordinator at domain level since only single integration instance is permitted. hass.data[DOMAIN][VS_COORDINATOR] = coordinator - switches = hass.data[DOMAIN][VS_SWITCHES] = [] - fans = hass.data[DOMAIN][VS_FANS] = [] - lights = hass.data[DOMAIN][VS_LIGHTS] = [] - sensors = hass.data[DOMAIN][VS_SENSORS] = [] - platforms = [] + hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager) - if device_dict[VS_SWITCHES]: - switches.extend(device_dict[VS_SWITCHES]) - platforms.append(Platform.SWITCH) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - if device_dict[VS_FANS]: - fans.extend(device_dict[VS_FANS]) - platforms.append(Platform.FAN) + @callback + def _async_handle_logging_changed(_event: Event) -> None: + """Handle when the logging level changes.""" + manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG - if device_dict[VS_LIGHTS]: - lights.extend(device_dict[VS_LIGHTS]) - platforms.append(Platform.LIGHT) + cleanup = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, _async_handle_logging_changed + ) - if device_dict[VS_SENSORS]: - sensors.extend(device_dict[VS_SENSORS]) - platforms.append(Platform.SENSOR) - - await hass.config_entries.async_forward_entry_setups(config_entry, platforms) + hass.data[DOMAIN][VS_LISTENERS] = cleanup async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] - switches = hass.data[DOMAIN][VS_SWITCHES] - fans = hass.data[DOMAIN][VS_FANS] - lights = hass.data[DOMAIN][VS_LIGHTS] - sensors = hass.data[DOMAIN][VS_SENSORS] + devices = hass.data[DOMAIN][VS_DEVICES] - dev_dict = await async_process_devices(hass, manager) - switch_devs = dev_dict.get(VS_SWITCHES, []) - fan_devs = dev_dict.get(VS_FANS, []) - light_devs = dev_dict.get(VS_LIGHTS, []) - sensor_devs = dev_dict.get(VS_SENSORS, []) + new_devices = await async_generate_device_list(hass, manager) - switch_set = set(switch_devs) - new_switches = list(switch_set.difference(switches)) - if new_switches and switches: - switches.extend(new_switches) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SWITCHES), new_switches) + device_set = set(new_devices) + new_devices = list(device_set.difference(devices)) + if new_devices and devices: + devices.extend(new_devices) + async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices) return - if new_switches and not switches: - switches.extend(new_switches) - hass.async_create_task(forward_setups(config_entry, [Platform.SWITCH])) - - fan_set = set(fan_devs) - new_fans = list(fan_set.difference(fans)) - if new_fans and fans: - fans.extend(new_fans) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_FANS), new_fans) - return - if new_fans and not fans: - fans.extend(new_fans) - hass.async_create_task(forward_setups(config_entry, [Platform.FAN])) - - light_set = set(light_devs) - new_lights = list(light_set.difference(lights)) - if new_lights and lights: - lights.extend(new_lights) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_LIGHTS), new_lights) - return - if new_lights and not lights: - lights.extend(new_lights) - hass.async_create_task(forward_setups(config_entry, [Platform.LIGHT])) - - sensor_set = set(sensor_devs) - new_sensors = list(sensor_set.difference(sensors)) - if new_sensors and sensors: - sensors.extend(new_sensors) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_SENSORS), new_sensors) - return - if new_sensors and not sensors: - sensors.extend(new_sensors) - hass.async_create_task(forward_setups(config_entry, [Platform.SENSOR])) + if new_devices and not devices: + devices.extend(new_devices) hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery @@ -142,19 +110,43 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - in_use_platforms = [] - if hass.data[DOMAIN][VS_SWITCHES]: - in_use_platforms.append(Platform.SWITCH) - if hass.data[DOMAIN][VS_FANS]: - in_use_platforms.append(Platform.FAN) - if hass.data[DOMAIN][VS_LIGHTS]: - in_use_platforms.append(Platform.LIGHT) - if hass.data[DOMAIN][VS_SENSORS]: - in_use_platforms.append(Platform.SENSOR) - unload_ok = await hass.config_entries.async_unload_platforms( - entry, in_use_platforms - ) + hass.data[DOMAIN][VS_LISTENERS]() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating VeSync config entry: %s minor version: %s", + config_entry.version, + config_entry.minor_version, + ) + if config_entry.minor_version == 1: + # Migrate switch/outlets entity to a new unique ID + _LOGGER.debug("Migrating VeSync config entry from version 1 to version 2") + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for reg_entry in registry_entries: + if "-" not in reg_entry.unique_id and reg_entry.entity_id.startswith( + Platform.SWITCH + ): + _LOGGER.debug( + "Migrating switch/outlet entity from unique_id: %s to unique_id: %s", + reg_entry.unique_id, + reg_entry.unique_id + "-device_status", + ) + entity_registry.async_update_entity( + reg_entry.entity_id, + new_unique_id=reg_entry.unique_id + "-device_status", + ) + else: + _LOGGER.debug("Skipping entity with unique_id: %s", reg_entry.unique_id) + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + return True diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py new file mode 100644 index 00000000000..7b6f14e04dc --- /dev/null +++ b/homeassistant/components/vesync/binary_sensor.py @@ -0,0 +1,105 @@ +"""Binary Sensor for VeSync.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import rgetattr +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes custom binary sensor entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( + VeSyncBinarySensorEntityDescription( + key="water_lacks", + translation_key="water_lacks", + is_on=lambda device: device.water_lacks, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + VeSyncBinarySensorEntityDescription( + key="details.water_tank_lifted", + translation_key="water_tank_lifted", + is_on=lambda device: device.details["water_tank_lifted"], + device_class=BinarySensorDeviceClass.PROBLEM, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary_sensor platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities(devices, async_add_entities, coordinator): + """Add entity.""" + async_add_entities( + ( + VeSyncBinarySensor(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if rgetattr(dev, description.key) is not None + ), + ) + + +class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): + """Vesync binary sensor class.""" + + entity_description: VeSyncBinarySensorEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncBinarySensorEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 5412b4f970c..f817c1d0714 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -4,48 +4,67 @@ import logging from pyvesync import VeSync from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant -from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES +from .const import VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) -async def async_process_devices( +def rgetattr(obj: object, attr: str): + """Return a string in the form word.1.2.3 and return the item as 3. Note that this last value could be in a dict as well.""" + _this_func = rgetattr + sp = attr.split(".", 1) + if len(sp) == 1: + left, right = sp[0], "" + else: + left, right = sp + + if isinstance(obj, dict): + obj = obj.get(left) + elif hasattr(obj, left): + obj = getattr(obj, left) + else: + return None + + if right: + obj = _this_func(obj, right) + + return obj + + +async def async_generate_device_list( hass: HomeAssistant, manager: VeSync -) -> dict[str, list[VeSyncBaseDevice]]: +) -> list[VeSyncBaseDevice]: """Assign devices to proper component.""" - devices: dict[str, list[VeSyncBaseDevice]] = {} - devices[VS_SWITCHES] = [] - devices[VS_FANS] = [] - devices[VS_LIGHTS] = [] - devices[VS_SENSORS] = [] + devices: list[VeSyncBaseDevice] = [] await hass.async_add_executor_job(manager.update) - if manager.fans: - devices[VS_FANS].extend(manager.fans) - # Expose fan sensors separately - devices[VS_SENSORS].extend(manager.fans) - _LOGGER.debug("%d VeSync fans found", len(manager.fans)) - - if manager.bulbs: - devices[VS_LIGHTS].extend(manager.bulbs) - _LOGGER.debug("%d VeSync lights found", len(manager.bulbs)) - - if manager.outlets: - devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' voltage, power & energy usage as separate sensors - devices[VS_SENSORS].extend(manager.outlets) - _LOGGER.debug("%d VeSync outlets found", len(manager.outlets)) - - if manager.switches: - for switch in manager.switches: - if not switch.is_dimmable(): - devices[VS_SWITCHES].append(switch) - else: - devices[VS_LIGHTS].append(switch) - _LOGGER.debug("%d VeSync switches found", len(manager.switches)) + devices.extend(manager.fans) + devices.extend(manager.bulbs) + devices.extend(manager.outlets) + devices.extend(manager.switches) return devices + + +def is_humidifier(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a humidifier.""" + + return isinstance(device, VeSyncHumidifierDevice) + + +def is_outlet(device: VeSyncBaseDevice) -> bool: + """Check if the device represents an outlet.""" + + return isinstance(device, VeSyncOutlet) + + +def is_wall_switch(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a wall switch, note this doessn't include dimming switches.""" + + return isinstance(device, VeSyncWallSwitch) diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 6115cb9ee76..e5537d8fcc9 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -1,5 +1,6 @@ """Config flow utilities.""" +from collections.abc import Mapping from typing import Any from pyvesync import VeSync @@ -8,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN @@ -24,6 +25,7 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 + MINOR_VERSION = 2 @callback def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: @@ -56,3 +58,36 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password}, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication with vesync.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication with vesync.""" + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + manager = VeSync(username, password) + login = await self.hass.async_add_executor_job(manager.login) + if login: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + description_placeholders={"name": "VeSync"}, + errors={"base": "invalid_auth"}, + ) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 2a8c5722340..1273ab914f8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,5 +1,7 @@ """Constants for VeSync Component.""" +from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S + DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" SERVICE_UPDATE_DEVS = "update_devices" @@ -17,13 +19,23 @@ total would be 2880. Using 30 seconds interval gives 8640 for 3 devices which exceeds the quota of 7700. """ - -VS_SWITCHES = "switches" -VS_FANS = "fans" -VS_LIGHTS = "lights" -VS_SENSORS = "sensors" +VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_LISTENERS = "listeners" +VS_NUMBERS = "numbers" + +VS_HUMIDIFIER_MODE_AUTO = "auto" +VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" +VS_HUMIDIFIER_MODE_MANUAL = "manual" +VS_HUMIDIFIER_MODE_SLEEP = "sleep" + +NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +NIGHT_LIGHT_LEVEL_DIM = "dim" +NIGHT_LIGHT_LEVEL_OFF = "off" + +VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S +"""Humidifier device types""" DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", @@ -41,6 +53,7 @@ DEV_TYPE_TO_HA = { "EverestAir": "fan", "Vital200S": "fan", "Vital100S": "fan", + "SmartTowerFan": "fan", "ESD16": "walldimmer", "ESWD16": "walldimmer", "ESL100": "bulb-dimmable", @@ -51,12 +64,14 @@ SKU_TO_BASE_DEVICE = { # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S + "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S "Core300S": "Core300S", "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S "LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S + "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S "Core400S": "Core400S", "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S @@ -83,4 +98,9 @@ SKU_TO_BASE_DEVICE = { "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir + "SmartTowerFan": "SmartTowerFan", + "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan } diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py index f3df2970fdb..e8c8396bfb4 100644 --- a/homeassistant/components/vesync/coordinator.py +++ b/homeassistant/components/vesync/coordinator.py @@ -7,6 +7,7 @@ import logging from pyvesync import VeSync +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -18,13 +19,18 @@ _LOGGER = logging.getLogger(__name__) class VeSyncDataCoordinator(DataUpdateCoordinator[None]): """Class representing data coordinator for VeSync devices.""" - def __init__(self, hass: HomeAssistant, manager: VeSync) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync + ) -> None: """Initialize.""" self._manager = manager super().__init__( hass, _LOGGER, + config_entry=config_entry, name="VeSyncDataCoordinator", update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index c6d61feebef..daf734d50a8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -24,8 +24,8 @@ from .const import ( DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_FANS, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -36,6 +36,9 @@ FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" FAN_MODE_TURBO = "turbo" +FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +FAN_MODE_NORMAL = "normal" + PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -46,6 +49,12 @@ PRESET_MODES = { "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], + "SmartTowerFan": [ + FAN_MODE_ADVANCED_SLEEP, + FAN_MODE_AUTO, + FAN_MODE_TURBO, + FAN_MODE_NORMAL, + ], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -56,13 +65,14 @@ SPEED_RANGE = { # off is not included "EverestAir": (1, 3), "Vital200S": (1, 4), "Vital100S": (1, 4), + "SmartTowerFan": (1, 13), } async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the VeSync fan platform.""" @@ -74,10 +84,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback @@ -86,16 +96,12 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" - entities = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan": - entities.append(VeSyncFanHA(dev, coordinator)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue + """Check if device is fan and add entity.""" + entities = [ + VeSyncFanHA(dev, coordinator) + for dev in devices + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan" + ] async_add_entities(entities, update_before_add=True) @@ -155,11 +161,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return self.smartfan.mode return None - @property - def unique_info(self): - """Return the ID of this fan.""" - return self.smartfan.uuid - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" @@ -216,10 +217,14 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): self.smartfan.auto_mode() elif preset_mode == FAN_MODE_SLEEP: self.smartfan.sleep_mode() + elif preset_mode == FAN_MODE_ADVANCED_SLEEP: + self.smartfan.advanced_sleep_mode() elif preset_mode == FAN_MODE_PET: self.smartfan.pet_mode() elif preset_mode == FAN_MODE_TURBO: self.smartfan.turbo_mode() + elif preset_mode == FAN_MODE_NORMAL: + self.smartfan.normal_mode() self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py new file mode 100644 index 00000000000..9a98a39aa8c --- /dev/null +++ b/homeassistant/components/vesync/humidifier.py @@ -0,0 +1,194 @@ +"""Support for VeSync humidifiers.""" + +import logging +from typing import Any + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.humidifier import ( + MODE_AUTO, + MODE_NORMAL, + MODE_SLEEP, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import is_humidifier +from .const import ( + DOMAIN, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + VeSyncHumidifierDevice, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +MIN_HUMIDITY = 30 +MAX_HUMIDITY = 80 + +VS_TO_HA_MODE_MAP = { + VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO, + VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL: MODE_NORMAL, + VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the VeSync humidifier platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddConfigEntryEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add humidifier entities.""" + async_add_entities( + VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev) + ) + + +def _get_ha_mode(vs_mode: str) -> str | None: + ha_mode = VS_TO_HA_MODE_MAP.get(vs_mode) + if ha_mode is None: + _LOGGER.warning("Unknown mode '%s'", vs_mode) + return ha_mode + + +class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): + """Representation of a VeSync humidifier.""" + + # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name + _attr_name = None + + _attr_max_humidity = MAX_HUMIDITY + _attr_min_humidity = MIN_HUMIDITY + _attr_supported_features = HumidifierEntityFeature.MODES + + device: VeSyncHumidifierDevice + + def __init__( + self, + device: VeSyncBaseDevice, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSyncHumidifierHA device.""" + super().__init__(device, coordinator) + + # 2 Vesync humidifier modes (humidity and auto) maps to the HA mode auto. + # They are on different devices though. We need to map HA mode to the + # device specific mode when setting it. + + self._ha_to_vs_mode_map: dict[str, str] = {} + self._available_modes: list[str] = [] + + # Populate maps once. + for vs_mode in self.device.mist_modes: + ha_mode = _get_ha_mode(vs_mode) + if ha_mode: + self._available_modes.append(ha_mode) + self._ha_to_vs_mode_map[ha_mode] = vs_mode + + self._available_modes.sort() + + def _get_vs_mode(self, ha_mode: str) -> str | None: + return self._ha_to_vs_mode_map.get(ha_mode) + + @property + def available_modes(self) -> list[str]: + """Return the available mist modes.""" + return self._available_modes + + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return self.device.humidity + + @property + def target_humidity(self) -> int: + """Return the humidity we try to reach.""" + return self.device.auto_humidity + + @property + def mode(self) -> str | None: + """Get the current preset mode.""" + return None if self.device.mode is None else _get_ha_mode(self.device.mode) + + def set_humidity(self, humidity: int) -> None: + """Set the target humidity of the device.""" + if not self.device.set_humidity(humidity): + raise HomeAssistantError( + f"An error occurred while setting humidity {humidity}." + ) + + def set_mode(self, mode: str) -> None: + """Set the mode of the device.""" + if mode not in self.available_modes: + raise HomeAssistantError( + f"{mode} is not one of the valid available modes: {self.available_modes}" + ) + if not self.device.set_humidity_mode(self._get_vs_mode(mode)): + raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + + if mode == MODE_SLEEP: + # We successfully changed the mode. Consider it a success even if display operation fails. + self.device.set_display(False) + + # Changing mode while humidifier is off actually turns it on, as per the app. But + # the library does not seem to update the device_status. It is also possible that + # other attributes get updated. Scheduling a forced refresh to get device status. + # updated. + self.schedule_update_ha_state(force_refresh=True) + + def turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") + + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + + self.schedule_update_ha_state() + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json index e4769acc9a5..c11bd002049 100644 --- a/homeassistant/components/vesync/icons.json +++ b/homeassistant/components/vesync/icons.json @@ -7,6 +7,7 @@ "state": { "auto": "mdi:fan-auto", "sleep": "mdi:sleep", + "advanced_sleep": "mdi:sleep", "pet": "mdi:paw", "turbo": "mdi:weather-tornado" } diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 84324e0af6e..887400b2cf0 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,10 +14,10 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -29,7 +29,7 @@ MIN_MIREDS = 153 # 1,000,000 divided by 6500 Kelvin = 153 Mireds async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights.""" @@ -41,10 +41,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_LIGHTS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback @@ -53,18 +53,13 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is online and add entity.""" + """Check if device is a light and add entity.""" entities: list[VeSyncBaseLightHA] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): entities.append(VeSyncDimmableLightHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) - else: - _LOGGER.debug( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue async_add_entities(entities, update_before_add=True) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index a706b2157ba..571c6ee0036 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,10 +1,16 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], + "codeowners": [ + "@markperdue", + "@webdjoe", + "@thegardenmonkey", + "@cdnninja", + "@iprak" + ], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", - "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.15"] + "loggers": ["pyvesync.vesync"], + "requirements": ["pyvesync==2.1.18"] } diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py new file mode 100644 index 00000000000..707dd6ab30e --- /dev/null +++ b/homeassistant/components/vesync/number.py @@ -0,0 +1,114 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import is_humidifier +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncNumberEntityDescription(NumberEntityDescription): + """Class to describe a Vesync number entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + value_fn: Callable[[VeSyncBaseDevice], float] + set_value_fn: Callable[[VeSyncBaseDevice, float], bool] + + +NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ + VeSyncNumberEntityDescription( + key="mist_level", + translation_key="mist_level", + native_min_value=1, + native_max_value=9, + native_step=1, + mode=NumberMode.SLIDER, + exists_fn=is_humidifier, + set_value_fn=lambda device, value: device.set_mist_level(value), + value_fn=lambda device: device.mist_level, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddConfigEntryEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add number entities.""" + + async_add_entities( + VeSyncNumberEntity(dev, description, coordinator) + for dev in devices + for description in NUMBER_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncNumberEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncNumberEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync number device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def native_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.value_fn(self.device) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + if await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.device, value + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py new file mode 100644 index 00000000000..c266985fc2b --- /dev/null +++ b/homeassistant/components/vesync/select.py @@ -0,0 +1,133 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import rgetattr +from .const import ( + DOMAIN, + NIGHT_LIGHT_LEVEL_BRIGHT, + NIGHT_LIGHT_LEVEL_DIM, + NIGHT_LIGHT_LEVEL_OFF, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + +VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { + 100: NIGHT_LIGHT_LEVEL_BRIGHT, + 50: NIGHT_LIGHT_LEVEL_DIM, + 0: NIGHT_LIGHT_LEVEL_OFF, +} + +HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +} + + +@dataclass(frozen=True, kw_only=True) +class VeSyncSelectEntityDescription(SelectEntityDescription): + """Class to describe a Vesync select entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + current_option_fn: Callable[[VeSyncBaseDevice], str] + select_option_fn: Callable[[VeSyncBaseDevice, str], bool] + + +SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "night_light"), + # The select_option service framework ensures that only options specified are + # accepted. ServiceValidationError gets raised for invalid value. + select_option_fn=lambda device, value: device.set_night_light_brightness( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + ), + # Reporting "off" as the choice for unhandled level. + current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up select entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddConfigEntryEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add select entities.""" + + async_add_entities( + VeSyncSelectEntity(dev, description, coordinator) + for dev in devices + for description in SELECT_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncSelectEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSelectEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync select device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def current_option(self) -> str | None: + """Return an option.""" + return self.entity_description.current_option_fn(self.device) + + async def async_select_option(self, option: str) -> None: + """Set an option.""" + if await self.hass.async_add_executor_job( + self.entity_description.select_option_fn, self.device, option + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index f283e3a3c0a..3bc6608989a 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -7,9 +7,6 @@ from dataclasses import dataclass import logging from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from pyvesync.vesyncfan import VeSyncAirBypass -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncSwitch from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,16 +25,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from .common import is_humidifier from .const import ( DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, + VS_DEVICES, VS_DISCOVERY, - VS_SENSORS, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -49,14 +47,10 @@ _LOGGER = logging.getLogger(__name__) class VeSyncSensorEntityDescription(SensorEntityDescription): """Describe VeSync sensor entity.""" - value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] + value_fn: Callable[[VeSyncBaseDevice], StateType] - exists_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool] = ( - lambda _: True - ) - update_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None] = ( - lambda _: None - ) + exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True + update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None def update_energy(device): @@ -186,13 +180,21 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( update_fn=update_energy, exists_fn=lambda device: ha_dev_type(device) == "outlet", ), + VeSyncSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["humidity"], + exists_fn=is_humidifier, + ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" @@ -204,16 +206,16 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SENSORS), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback def _setup_entities( devices: list[VeSyncBaseDevice], - async_add_entities, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ): """Check if device is online and add entity.""" @@ -236,9 +238,9 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): def __init__( self, - device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, + device: VeSyncBaseDevice, description: VeSyncSensorEntityDescription, - coordinator, + coordinator: VeSyncDataCoordinator, ) -> None: """Initialize the VeSync outlet device.""" super().__init__(device, coordinator) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index b6e4e2fd957..eabb2969580 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -2,7 +2,15 @@ "config": { "step": { "user": { - "title": "Enter Username and Password", + "title": "Enter username and password", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The VeSync integration needs to re-authenticate your account", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -13,7 +21,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -43,6 +52,29 @@ "name": "Current voltage" } }, + "binary_sensor": { + "water_lacks": { + "name": "Low water" + }, + "water_tank_lifted": { + "name": "Water tank lifted" + } + }, + "number": { + "mist_level": { + "name": "Mist level" + } + }, + "select": { + "night_light_level": { + "name": "Night light level", + "state": { + "bright": "Bright", + "dim": "Dim", + "off": "[%key:common::state::off%]" + } + } + }, "fan": { "vesync": { "state_attributes": { @@ -50,6 +82,7 @@ "state": { "auto": "Auto", "sleep": "Sleep", + "advanced_sleep": "Advanced sleep", "pet": "Pet", "turbo": "Turbo" } diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 0b69ca3d44a..3e8deedb4ad 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -1,29 +1,59 @@ """Support for VeSync switches.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from typing import Any +from typing import Any, Final from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_SWITCHES +from .common import is_outlet, is_wall_switch +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class VeSyncSwitchEntityDescription(SwitchEntityDescription): + """A class that describes custom switch entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + exists_fn: Callable[[VeSyncBaseDevice], bool] + on_fn: Callable[[VeSyncBaseDevice], bool] + off_fn: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( + VeSyncSwitchEntityDescription( + key="device_status", + is_on=lambda device: device.device_status == "on", + # Other types of wall switches support dimming. Those use light.py platform. + exists_fn=lambda device: is_wall_switch(device) or is_outlet(device), + name=None, + on_fn=lambda device: device.turn_on(), + off_fn=lambda device: device.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up switch platform.""" coordinator = hass.data[DOMAIN][VS_COORDINATOR] @@ -33,10 +63,10 @@ async def async_setup_entry( _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( - async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SWITCHES), discover) + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities, coordinator) + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) @callback @@ -46,57 +76,45 @@ def _setup_entities( coordinator: VeSyncDataCoordinator, ): """Check if device is online and add entity.""" - entities: list[VeSyncBaseSwitch] = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": - entities.append(VeSyncSwitchHA(dev, coordinator)) - elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": - entities.append(VeSyncLightSwitch(dev, coordinator)) - else: - _LOGGER.warning( - "%s - Unknown device type - %s", dev.device_name, dev.device_type - ) - continue - - async_add_entities(entities, update_before_add=True) + async_add_entities( + VeSyncSwitchEntity(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if description.exists_fn(dev) + ) -class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity): - """Base class for VeSync switch Device Representations.""" +class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): + """VeSync switch entity class.""" - _attr_name = None + entity_description: VeSyncSwitchEntityDescription - def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - self.device.turn_on() + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSwitchEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + if is_outlet(self.device): + self._attr_device_class = SwitchDeviceClass.OUTLET + elif is_wall_switch(self.device): + self._attr_device_class = SwitchDeviceClass.SWITCH @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" + def is_on(self) -> bool | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.is_on(self.device) def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() + """Turn the entity off.""" + if self.entity_description.off_fn(self.device): + self.schedule_update_ha_state() - -class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): - """Representation of a VeSync switch.""" - - def __init__( - self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync switch device.""" - super().__init__(plug, coordinator) - self.smartplug = plug - - -class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): - """Handle representation of VeSync Light Switch.""" - - def __init__( - self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize Light Switch device class.""" - super().__init__(switch, coordinator) - self.switch = switch + def turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if self.entity_description.on_fn(self.device): + self.schedule_update_ha_state() diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index cb652270c69..4a75f5cccd2 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 9c331f0e9ec..12d8ba520f1 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -146,7 +146,8 @@ async def async_migrate_devices_and_entities( # to `-heating-` if entity_entry.domain == DOMAIN_CLIMATE: unique_id_parts[len(unique_id_parts) - 1] = ( - f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" + f"{entity_entry.translation_key}-" + f"{unique_id_parts[len(unique_id_parts) - 1]}" ) entity_new_unique_id = "-".join(unique_id_parts) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index ced02dae97e..902dfd18d30 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin @@ -106,6 +106,12 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), ), + ViCareBinarySensorEntityDescription( + key="one_time_charge", + translation_key="one_time_charge", + device_class=BinarySensorDeviceClass.RUNNING, + value_getter=lambda api: api.getOneTimeCharge(), + ), ) @@ -125,7 +131,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -143,7 +149,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities @@ -151,7 +157,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" async_add_entities( diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ad7d600eba3..9c30a9e68ee 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -18,7 +18,7 @@ import requests from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixinWithSet @@ -59,14 +59,14 @@ def _build_entities( ) for device in device_list for description in BUTTON_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ] async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare button entities.""" async_add_entities( diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 62231a4e2fe..9fba83c5700 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -32,9 +32,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ViCareEntity @@ -99,7 +98,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 6594e6ec9e4..c1d4adda62a 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -12,11 +12,11 @@ from PyViCare.PyViCareUtils import ( ) import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_HEATING_TYPE, @@ -109,7 +109,7 @@ class ViCareConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Invoke when a Viessmann MAC address is discovered on the network.""" formatted_mac = format_mac(discovery_info.macaddress) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 2d858185b9f..7b73d2e5ba3 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -28,8 +28,13 @@ class ViCareEntity(Entity): """Initialize the entity.""" gateway_serial = device_config.getConfig().serial device_id = device_config.getId() + model = device_config.getModel().replace("_", " ") - identifier = f"{gateway_serial}_{device_serial.replace("zigbee-", "zigbee_") if device_serial is not None else device_id}" + identifier = ( + f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}" + if device_serial is not None + else f"{gateway_serial}_{device_id}" + ) self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( component if component else device @@ -41,8 +46,8 @@ class ViCareEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, serial_number=device_serial, - name=device_config.getModel(), + name=model, manufacturer="Viessmann", - model=device_config.getModel(), + model=model, configuration_url="https://developer.viessmann.com/", ) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 69aa8396fea..d84b2038dde 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -5,6 +5,7 @@ from __future__ import annotations from contextlib import suppress import enum import logging +from typing import Any from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig @@ -13,14 +14,11 @@ from PyViCare.PyViCareUtils import ( PyViCareNotSupportedFeatureError, PyViCareRateLimitError, ) -from PyViCare.PyViCareVentilationDevice import ( - VentilationDevice as PyViCareVentilationDevice, -) from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -28,7 +26,7 @@ from homeassistant.util.percentage import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice -from .utils import get_device_serial +from .utils import filter_state, get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -50,6 +48,8 @@ class VentilationMode(enum.StrEnum): PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour) VENTILATION = "ventilation" # activated by schedule + STANDBY = "standby" # activated by schedule + STANDARD = "standard" # activated by schedule SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor SENSOR_OVERRIDE = "sensor_override" # activated by sensor @@ -74,9 +74,17 @@ class VentilationMode(enum.StrEnum): return None +class VentilationQuickmode(enum.StrEnum): + """ViCare ventilation quickmodes.""" + + STANDBY = "standby" + + HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", + VentilationMode.STANDBY: "standby", + VentilationMode.STANDARD: "standard", VentilationMode.SENSOR_DRIVEN: "sensorDriven", VentilationMode.SENSOR_OVERRIDE: "sensorOverride", } @@ -96,14 +104,14 @@ def _build_entities( return [ ViCareFan(get_device_serial(device.api), device.config, device.api) for device in device_list - if isinstance(device.api, PyViCareVentilationDevice) + if device.api.isVentilationDevice() ] async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ViCare fan platform.""" async_add_entities( @@ -118,7 +126,6 @@ class ViCareFan(ViCareEntity, FanEntity): """Representation of the ViCare ventilation device.""" _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" def __init__( @@ -131,8 +138,8 @@ class ViCareFan(ViCareEntity, FanEntity): super().__init__( self._attr_translation_key, device_serial, device_config, device ) - # init presets - supported_modes = list[str](self._api.getAvailableModes()) + # init preset_mode + supported_modes = list[str](self._api.getVentilationModes()) self._attr_preset_modes = [ mode for mode in VentilationMode @@ -140,18 +147,43 @@ class ViCareFan(ViCareEntity, FanEntity): ] if len(self._attr_preset_modes) > 0: self._attr_supported_features |= FanEntityFeature.PRESET_MODE + # init set_speed + supported_levels: list[str] | None = None + with suppress(PyViCareNotSupportedFeatureError): + supported_levels = self._api.getVentilationLevels() + if supported_levels is not None and len(supported_levels) > 0: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + + # evaluate quickmodes + quickmodes: list[str] = ( + device.getVentilationQuickmodes() + if is_supported( + "getVentilationQuickmodes", + lambda api: api.getVentilationQuickmodes(), + device, + ) + else [] + ) + if VentilationQuickmode.STANDBY in quickmodes: + self._attr_supported_features |= FanEntityFeature.TURN_OFF def update(self) -> None: """Update state of fan.""" + level: str | None = None try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( - self._api.getActiveMode() + self._api.getActiveVentilationMode() ) + with suppress(PyViCareNotSupportedFeatureError): + level = filter_state(self._api.getVentilationLevel()) + if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: self._attr_percentage = ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._api.getActiveProgram() + ORDERED_NAMED_FAN_SPEEDS, VentilationProgram(level) ) + else: + self._attr_percentage = 0 except RequestConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: @@ -164,12 +196,27 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - # Viessmann ventilation unit cannot be turned off - return True + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): + return False + + return self.percentage is not None and self.percentage > 0 + + def turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + + self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): + return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: return "mdi:fan-clock" @@ -195,13 +242,15 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) + elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) - self._api.setPermanentLevel(level) + self._api.setVentilationLevel(level) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) - self._api.setActiveMode(target_mode) + self._api.activateVentilationMode(target_mode) diff --git a/homeassistant/components/vicare/icons.json b/homeassistant/components/vicare/icons.json index 9d0f27a863c..c54be7af0d5 100644 --- a/homeassistant/components/vicare/icons.json +++ b/homeassistant/components/vicare/icons.json @@ -18,6 +18,9 @@ }, "domestic_hot_water_pump": { "default": "mdi:pump" + }, + "one_time_charge": { + "default": "mdi:shower-head" } }, "button": { @@ -84,6 +87,9 @@ }, "compressor_phase": { "default": "mdi:information" + }, + "ventilation_level": { + "default": "mdi:fan" } } }, diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 98ff6ce4c82..e39adaf6c4c 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.39.1"] + "requirements": ["PyViCare==2.43.1"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 8ffaa727634..04c4088bd3e 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -27,7 +27,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ( @@ -353,7 +353,7 @@ def _build_entities( device.api, ) for description in DEVICE_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities entities.extend( @@ -366,7 +366,7 @@ def _build_entities( ) for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, circuit) + if is_supported(description.key, description.value_getter, circuit) ) return entities @@ -374,7 +374,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare number devices.""" async_add_entities( diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml index 55b7590a092..81b03364142 100644 --- a/homeassistant/components/vicare/quality_scale.yaml +++ b/homeassistant/components/vicare/quality_scale.yaml @@ -1,43 +1,70 @@ rules: # Bronze - config-flow: done - test-before-configure: done - unique-config-entry: - status: todo - comment: Uniqueness is not checked yet. - config-flow-test-coverage: done - runtime-data: done - test-before-setup: done - appropriate-polling: done - entity-unique-id: done - has-entity-name: done - entity-event-setup: - status: exempt - comment: Entities of this integration does not explicitly subscribe to events. - dependency-transparency: done action-setup: status: todo comment: service registered in climate async_setup_entry. + appropriate-polling: done + brands: done common-modules: status: done comment: No coordinator is used, data update is centrally handled by the library. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - docs-actions: done - brands: done + entity-event-setup: + status: exempt + comment: Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: todo + comment: Uniqueness is not checked yet. + # Silver - integration-owner: done - reauthentication-flow: done + action-exceptions: todo config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + # Gold devices: done diagnostics: done - entity-category: done + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo dynamic-devices: done + entity-category: done entity-device-class: done - entity-translations: done entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo repair-issues: status: exempt comment: This integration does not raise any repairable issues. + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 3386c849f74..cddc5ca021a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -29,14 +29,16 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, UnitOfEnergy, + UnitOfMass, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( VICARE_CUBIC_METER, @@ -49,6 +51,7 @@ from .const import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin from .utils import ( + filter_state, get_burners, get_circuits, get_compressors, @@ -633,6 +636,38 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="buffer_mid_top_temperature", + translation_key="buffer_mid_top_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferMidTopTemperature(), + ), + ViCareSensorEntityDescription( + key="buffer_middle_temperature", + translation_key="buffer_middle_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferMiddleTemperature(), + ), + ViCareSensorEntityDescription( + key="buffer_mid_bottom_temperature", + translation_key="buffer_mid_bottom_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferMidBottomTemperature(), + ), + ViCareSensorEntityDescription( + key="buffer_bottom_temperature", + translation_key="buffer_bottom_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferBottomTemperature(), + ), ViCareSensorEntityDescription( key="buffer main temperature", translation_key="buffer_main_temperature", @@ -796,7 +831,7 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( translation_key="photovoltaic_status", device_class=SensorDeviceClass.ENUM, options=["ready", "production"], - value_getter=lambda api: _filter_pv_states(api.getPhotovoltaicStatus()), + value_getter=lambda api: filter_state(api.getPhotovoltaicStatus()), ), ViCareSensorEntityDescription( key="room_temperature", @@ -812,6 +847,92 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_getter=lambda api: api.getHumidity(), ), + ViCareSensorEntityDescription( + key="ventilation_level", + translation_key="ventilation_level", + value_getter=lambda api: filter_state(api.getVentilationLevel().lower()), + device_class=SensorDeviceClass.ENUM, + options=["standby", "levelone", "leveltwo", "levelthree", "levelfour"], + ), + ViCareSensorEntityDescription( + key="ventilation_reason", + translation_key="ventilation_reason", + value_getter=lambda api: api.getVentilationReason().lower(), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=[ + "standby", + "permanent", + "schedule", + "sensordriven", + "silent", + "forcedlevelfour", + ], + ), + ViCareSensorEntityDescription( + key="supply_pressure", + translation_key="supply_pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSupplyPressure(), + unit_getter=lambda api: api.getSupplyPressureUnit(), + ), + ViCareSensorEntityDescription( + key="heating_rod_starts", + translation_key="heating_rod_starts", + value_getter=lambda api: api.getHeatingRodStarts(), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="heating_rod_hours", + translation_key="heating_rod_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + value_getter=lambda api: api.getHeatingRodHours(), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="spf_total", + translation_key="spf_total", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorTotal(), + ), + ViCareSensorEntityDescription( + key="spf_dhw", + translation_key="spf_dhw", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorDHW(), + ), + ViCareSensorEntityDescription( + key="spf_heating", + translation_key="spf_heating", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(), + ), + ViCareSensorEntityDescription( + key="battery_level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getBatteryLevel(), + ), + ViCareSensorEntityDescription( + key="fuel_need", + translation_key="fuel_need", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + value_getter=lambda api: api.getFuelNeed(), + unit_getter=lambda api: api.getFuelUnit(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -920,10 +1041,6 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _filter_pv_states(state: str) -> str | None: - return None if state in ("nothing", "unknown") else state - - def _build_entities( device_list: list[ViCareDevice], ) -> list[ViCareSensor]: @@ -940,7 +1057,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -958,7 +1075,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities @@ -966,7 +1083,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" async_add_entities( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 4934507e41c..733cda363e5 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -63,6 +63,9 @@ }, "domestic_hot_water_pump": { "name": "DHW pump" + }, + "one_time_charge": { + "name": "One-time charge" } }, "button": { @@ -81,10 +84,12 @@ "state_attributes": { "preset_mode": { "state": { - "permanent": "permanent", - "ventilation": "schedule", - "sensor_driven": "sensor", - "sensor_override": "schedule with sensor-override" + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "ventilation": "Schedule", + "sensor_driven": "Sensor-driven", + "sensor_override": "Schedule with sensor-override", + "standard": "Minimal" } } } @@ -333,6 +338,18 @@ "buffer_top_temperature": { "name": "Buffer top temperature" }, + "buffer_mid_top_temperature": { + "name": "Buffer mid top temperature" + }, + "buffer_middle_temperature": { + "name": "Buffer middle temperature" + }, + "buffer_mid_bottom_temperature": { + "name": "Buffer mid bottom temperature" + }, + "buffer_bottom_temperature": { + "name": "Buffer bottom temperature" + }, "buffer_main_temperature": { "name": "Buffer main temperature" }, @@ -375,25 +392,25 @@ "name": "Energy export to grid" }, "photovoltaic_power_production_current": { - "name": "Solar power" + "name": "PV power" }, "photovoltaic_energy_production_today": { - "name": "Solar energy production today" + "name": "PV energy production today" }, "photovoltaic_energy_production_this_week": { - "name": "Solar energy production this week" + "name": "PV energy production this week" }, "photovoltaic_energy_production_this_month": { - "name": "Solar energy production this month" + "name": "PV energy production this month" }, "photovoltaic_energy_production_this_year": { - "name": "Solar energy production this year" + "name": "PV energy production this year" }, "photovoltaic_energy_production_total": { - "name": "Solar energy production total" + "name": "PV energy production total" }, "photovoltaic_status": { - "name": "Solar state", + "name": "PV state", "state": { "ready": "Standby", "production": "Producing" @@ -434,6 +451,48 @@ }, "compressor_phase": { "name": "Compressor phase" + }, + "ventilation_level": { + "name": "Ventilation level", + "state": { + "standby": "[%key:common::state::standby%]", + "levelone": "1", + "leveltwo": "2", + "levelthree": "3", + "levelfour": "4" + } + }, + "ventilation_reason": { + "name": "Ventilation reason", + "state": { + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "schedule": "Schedule", + "sensordriven": "Sensor-driven", + "silent": "Silent", + "forcedlevelfour": "Boost" + } + }, + "supply_pressure": { + "name": "Supply pressure" + }, + "heating_rod_starts": { + "name": "Heating rod starts" + }, + "heating_rod_hours": { + "name": "Heating rod hours" + }, + "spf_total": { + "name": "Seasonal performance factor" + }, + "spf_dhw": { + "name": "Seasonal performance factor - domestic hot water" + }, + "spf_heating": { + "name": "Seasonal performance factor - heating" + }, + "fuel_need": { + "name": "Fuel need" } }, "water_heater": { diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 120dad83113..ef018a60f16 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -30,7 +30,7 @@ from .const import ( VICARE_TOKEN_FILENAME, HeatingType, ) -from .types import ViCareConfigEntry, ViCareRequiredKeysMixin +from .types import ViCareConfigEntry _LOGGER = logging.getLogger(__name__) @@ -81,12 +81,12 @@ def get_device_serial(device: PyViCareDevice) -> str | None: def is_supported( name: str, - entity_description: ViCareRequiredKeysMixin, + getter: Callable[[PyViCareDevice], Any], vicare_device, ) -> bool: """Check if the PyViCare device supports the requested sensor.""" try: - entity_description.value_getter(vicare_device) + getter(vicare_device) except PyViCareNotSupportedFeatureError: _LOGGER.debug("Feature not supported %s", name) return False @@ -128,3 +128,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone except AttributeError as error: _LOGGER.debug("No compressors found: %s", error) return [] + + +def filter_state(state: str) -> str | None: + """Return the state if not 'nothing' or 'unknown'.""" + return None if state in ("nothing", "unknown") else state diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 114ff620c3f..f92c9e3e1af 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -22,7 +22,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice @@ -80,7 +80,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ViCare water heater platform.""" async_add_entities( diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 77a7df7a0a8..fa2d5cae196 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_API_DATA_FIELD_BOOT_TIME, @@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Vilfo Router entities from a config_entry.""" vilfo = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 4af42d76b62..10a71695e05 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN) - coordinator = VizioAppsDataUpdateCoordinator(hass, store) + coordinator = VizioAppsDataUpdateCoordinator(hass, entry, store) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator @@ -39,12 +39,9 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - # Exclude this config entry because its not unloaded yet if not any( - entry.state is ConfigEntryState.LOADED - and entry.entry_id != config_entry.entry_id - and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - for entry in hass.config_entries.async_entries(DOMAIN) + entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV + for entry in hass.config_entries.async_loaded_entries(DOMAIN) ): hass.data[DOMAIN].pop(CONF_APPS, None) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index d3921061d8e..572f093dfd3 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -11,7 +11,6 @@ from pyvizio import VizioAsync, async_guess_device_type from pyvizio.const import APP_HOME import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.config_entries import ( SOURCE_ZEROCONF, @@ -32,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ip_address from .const import ( @@ -257,7 +257,7 @@ class VizioConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 8451ae747de..fbfaf222cad 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -10,7 +10,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType SERVICE_UPDATE_SETTING = "update_setting" diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index a7ca7d7f9ed..0f95c8a53b7 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -9,6 +9,7 @@ from typing import Any from pyvizio.const import APPS from pyvizio.util import gen_apps_list_from_url +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import Store @@ -22,11 +23,19 @@ _LOGGER = logging.getLogger(__name__) class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + store: Store[list[dict[str, Any]]], + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(days=1), ) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 5711d8fbac9..d44db5e45ee 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_ADDITIONAL_CONFIGS, @@ -63,7 +63,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Vizio media player entry.""" host = config_entry.data[CONF_HOST] diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 6091cd72f3f..2f97bb332e8 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -6,7 +6,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", - "device_class": "Device Type", + "device_class": "Device type", "access_token": "[%key:common::config_flow::data::access_token%]" }, "data_description": { @@ -14,25 +14,25 @@ } }, "pair_tv": { - "title": "Complete Pairing Process", + "title": "Complete pairing process", "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.", "data": { "pin": "[%key:common::config_flow::data::pin%]" } }, "pairing_complete": { - "title": "Pairing Complete", - "description": "Your VIZIO SmartCast Device is now connected to Home Assistant." + "title": "Pairing complete", + "description": "Your VIZIO SmartCast device is now connected to Home Assistant." }, "pairing_complete_import": { "title": "[%key:component::vizio::config::step::pairing_complete::title%]", - "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." + "description": "Your VIZIO SmartCast device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." } }, "error": { "complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." + "existing_config_entry_found": "An existing VIZIO SmartCast device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", @@ -43,12 +43,12 @@ "options": { "step": { "init": { - "title": "Update VIZIO SmartCast Device Options", + "title": "Update VIZIO SmartCast device options", "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list.", "data": { - "volume_step": "Volume Step Size", - "include_or_exclude": "Include or Exclude Apps?", - "apps_to_include_or_exclude": "Apps to Include or Exclude" + "volume_step": "Volume step size", + "include_or_exclude": "Include or exclude apps?", + "apps_to_include_or_exclude": "Apps to include or exclude" } } } diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index cd05c919d58..d1a481a99b1 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -20,10 +20,10 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index b95e987aef8..6ae9fbb9f5a 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import VlcConfigEntry from .const import DEFAULT_NAME, DOMAIN, LOGGER @@ -39,7 +39,9 @@ def _get_str(data: dict, key: str) -> str | None: async def async_setup_entry( - hass: HomeAssistant, entry: VlcConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: VlcConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the vlc platform.""" # CONF_NAME is only present in imported YAML. diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index b4c44ea9130..871afe09a2e 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - entry.unique_id, + entry, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index efea011a541..9812cef48d6 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN @@ -67,7 +67,9 @@ BUTTON_TYPES: Final = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station buttons") diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index de794488040..b7986d06c25 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,18 +3,21 @@ from dataclasses import dataclass from datetime import datetime, timedelta from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, cast from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import _LOGGER, DOMAIN, SCAN_INTERVAL +from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() @@ -39,13 +42,15 @@ class UpdateCoordinatorDataType: class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Queries router running Vodafone Station firmware.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, host: str, username: str, password: str, - config_entry_unique_id: str | None, + config_entry: ConfigEntry, ) -> None: """Initialize the scanner.""" @@ -53,14 +58,26 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.api = VodafoneStationSercommApi(host, username, password) # Last resort as no MAC or S/N can be retrieved via API - self._id = config_entry_unique_id + self._id = config_entry.unique_id super().__init__( hass=hass, logger=_LOGGER, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=SCAN_INTERVAL), + config_entry=config_entry, ) + device_reg = dr.async_get(self.hass) + device_list = dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ) + + self.previous_devices = { + connection[1].upper() + for device in device_list + for connection in device.connections + if connection[0] == dr.CONNECTION_NETWORK_MAC + } def _calculate_update_time_and_consider_home( self, device: VodafoneStationDevice, utc_point_in_time: datetime @@ -125,6 +142,18 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): ) for dev_info in (raw_data_devices).values() } + current_devices = set(data_devices) + _LOGGER.debug( + "Loaded current %s devices: %s", len(current_devices), current_devices + ) + if stale_devices := self.previous_devices - current_devices: + _LOGGER.debug( + "Found %s stale devices: %s", len(stale_devices), stale_devices + ) + await cleanup_device_tracker(self.hass, self.config_entry, data_devices) + + self.previous_devices = current_devices + return UpdateCoordinatorDataType(data_devices, data_sensors) @property @@ -135,7 +164,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): @property def serial_number(self) -> str: """Device serial number.""" - return self.data.sensors["sys_serial_number"] + return cast(str, self.data.sensors["sys_serial_number"]) @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 3e4d7763bff..ece4bd05a02 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN @@ -14,7 +14,9 @@ from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Vodafone Station component.""" @@ -40,7 +42,7 @@ async def async_setup_entry( @callback def async_add_new_tracked_entities( coordinator: VodafoneStationRouter, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from the router.""" @@ -61,6 +63,7 @@ class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEn """Representation of a Vodafone Station device.""" _attr_translation_key = "device_tracker" + _attr_has_entity_name = True mac_address: str def __init__( @@ -72,7 +75,9 @@ class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEn mac = device_info.device.mac self._attr_mac_address = mac self._attr_unique_id = mac - self._attr_hostname = device_info.device.name or mac.replace(":", "_") + self._attr_hostname = self._attr_name = device_info.device.name or mac.replace( + ":", "_" + ) @property def _device_info(self) -> VodafoneStationDeviceInfo: diff --git a/homeassistant/components/vodafone_station/helpers.py b/homeassistant/components/vodafone_station/helpers.py new file mode 100644 index 00000000000..aa0fda3f6be --- /dev/null +++ b/homeassistant/components/vodafone_station/helpers.py @@ -0,0 +1,72 @@ +"""Vodafone Station helpers.""" + +from typing import Any + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import _LOGGER + + +async def cleanup_device_tracker( + hass: HomeAssistant, config_entry: ConfigEntry, devices: dict[str, Any] +) -> None: + """Cleanup stale device tracker.""" + entity_reg: er.EntityRegistry = er.async_get(hass) + + entities_removed: bool = False + + device_hosts_macs: set[str] = set() + device_hosts_names: set[str] = set() + for mac, device_info in devices.items(): + device_hosts_macs.add(mac) + device_hosts_names.add(device_info.device.name) + + for entry in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id): + if entry.domain != DEVICE_TRACKER_DOMAIN: + continue + entry_name = entry.name or entry.original_name + entry_host = entry_name.partition(" ")[0] if entry_name else None + entry_mac = entry.unique_id.partition("_")[0] + + # Some devices, mainly routers, allow to change the hostname of the connected devices. + # This can lead to entities no longer aligned to the device UI + if ( + entry_host + and entry_host in device_hosts_names + and entry_mac in device_hosts_macs + ): + _LOGGER.debug( + "Skipping entity %s [mac=%s, host=%s]", + entry_name, + entry_mac, + entry_host, + ) + continue + # Entity is removed so that at the next coordinator update + # the correct one will be created + _LOGGER.info("Removing entity: %s", entry_name) + entity_reg.async_remove(entry.entity_id) + entities_removed = True + + if entities_removed: + _async_remove_empty_devices(hass, entity_reg, config_entry) + + +def _async_remove_empty_devices( + hass: HomeAssistant, entity_reg: er.EntityRegistry, config_entry: ConfigEntry +) -> None: + """Remove devices with no entities.""" + + device_reg = dr.async_get(hass) + device_list = dr.async_entries_for_config_entry(device_reg, config_entry.entry_id) + for device_entry in device_list: + if not er.async_entries_for_device( + entity_reg, + device_entry.id, + include_disabled_entities=True, + ): + _LOGGER.info("Removing device: %s", device_entry.name) + device_reg.async_remove_device(device_entry.id) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 307fcaf0ea8..d29fb7f21e9 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN, LINE_TYPES @@ -165,7 +165,9 @@ SENSOR_TYPES: Final = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station sensors") diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 9f1615ffa01..6bf42d86836 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index cee0cbb0766..96e758e91f4 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -30,9 +30,9 @@ _IP_WILDCARD = "0.0.0.0" __all__ = [ "DOMAIN", + "async_remove_config_entry_device", "async_setup_entry", "async_unload_entry", - "async_remove_config_entry_device", ] diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 0100435d6dc..a0aeaaf38d3 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -8,23 +8,37 @@ from functools import partial import io import logging from pathlib import Path +import socket +import time from typing import TYPE_CHECKING, Any, Final import wave -from voip_utils import RtpDatagramProtocol +from voip_utils import SIP_PORT, RtpDatagramProtocol +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, + AssistSatelliteEntityFeature, ) +from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .const import ( + CHANNELS, + CONF_SIP_PORT, + CONF_SIP_USER, + DOMAIN, + RATE, + RTP_AUDIO_SETTINGS, + WIDTH, +) from .devices import VoIPDevice from .entity import VoIPEntity @@ -34,6 +48,10 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 +_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 +_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 +_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -54,7 +72,7 @@ _TONE_FILENAMES: dict[Tones, str] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP Assist satellite entity.""" domain_data: DomainData = hass.data[DOMAIN] @@ -80,6 +98,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -105,6 +127,14 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._tones = tones self._processing_tone_done = asyncio.Event() + self._announcement: AssistSatelliteAnnouncement | None = None + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 + self._check_announcement_ended_task: asyncio.Task | None = None + self._last_chunk_time: float | None = None + self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -149,25 +179,149 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Set the current satellite configuration.""" raise NotImplementedError + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: + """Announce media on the satellite. + + Plays announcement in a loop, blocking until the caller hangs up. + """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ + self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after + + if self._rtp_port is None: + # Choose random port for RTP + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(("", 0)) + _rtp_ip, self._rtp_port = sock.getsockname() + sock.close() + + # HA SIP server + source_ip = await async_get_source_ip(self.hass) + sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT) + sip_user = self.config_entry.options.get(CONF_SIP_USER) + source_endpoint = get_sip_endpoint( + host=source_ip, port=sip_port, username=sip_user + ) + + try: + # VoIP ID is SIP header + destination_endpoint = SipEndpoint(self.voip_device.voip_id) + except ValueError: + # VoIP ID is IP address + destination_endpoint = get_sip_endpoint( + host=self.voip_device.voip_id, port=SIP_PORT + ) + + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() + self._announcement = announcement + + # Make the call + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( + source=source_endpoint, + destination=destination_endpoint, + rtp_port=self._rtp_port, + ) + + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise + + async def _check_announcement_ended(self) -> None: + """Continuously checks if an audio chunk was received within a time limit. + + If not, the caller is presumed to have hung up and the announcement is ended. + """ + while self._announcement is not None: + current_time = time.monotonic() + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + + if (self._last_chunk_time is not None) and ( + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + ): + # Caller hung up + self._announcement = None + self._announcement_future.set_result(None) + self._check_announcement_ended_task = None + _LOGGER.debug("Announcement ended") + break + + await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" - if self._run_pipeline_task is None: - # Run pipeline until voice command finishes, then start over - self._clear_audio_queue() - self._tts_done.clear() + self._last_chunk_time = time.monotonic() + + if self._announcement is None: + # Pipeline with STT + if self._run_pipeline_task is None: + # Run pipeline until voice command finishes, then start over + self._clear_audio_queue() + self._tts_done.clear() + self._run_pipeline_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._run_pipeline(), + "voip_pipeline_run", + ) + ) + + self._audio_queue.put_nowait(audio_bytes) + elif self._run_pipeline_task is None: + # Announcement only + # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, - self._run_pipeline(), - "voip_pipeline_run", + self._play_announcement(self._announcement), + "voip_play_announcement", ) - self._audio_queue.put_nowait(audio_bytes) - async def _run_pipeline(self) -> None: + """Run a pipeline with STT input and TTS output.""" _LOGGER.debug("Starting pipeline") self.async_set_context(Context(user_id=self.config_entry.data["user"])) @@ -209,6 +363,31 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Pipeline finished") + async def _play_announcement( + self, announcement: AssistSatelliteAnnouncement + ) -> None: + """Play an announcement once.""" + _LOGGER.debug("Playing announcement") + + try: + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + await self._send_tts(announcement.original_media_id, wait_for_tone=False) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + except Exception: + _LOGGER.exception("Unexpected error while playing announcement") + raise + finally: + self._run_pipeline_task = None + _LOGGER.debug("Announcement finished") + + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): @@ -239,7 +418,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str) -> None: + async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: @@ -253,7 +432,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if extension != "wav": raise ValueError(f"Only WAV audio can be streamed, got {extension}") - if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: + if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep _LOGGER.debug("Waiting for processing tone") await self._processing_tone_done.wait() diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index f38b228c46c..34dac4b6068 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import VoIPDevice @@ -24,7 +24,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP binary sensor entities.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py index 63dcb8f86ee..7ae603f0f6a 100644 --- a/homeassistant/components/voip/config_flow.py +++ b/homeassistant/components/voip/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from .const import CONF_SIP_PORT, DOMAIN +from .const import CONF_SIP_PORT, CONF_SIP_USER, DOMAIN class VoIPConfigFlow(ConfigFlow, domain=DOMAIN): @@ -58,7 +58,15 @@ class VoipOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + if CONF_SIP_USER in user_input and not user_input[CONF_SIP_USER]: + del user_input[CONF_SIP_USER] + self.hass.config_entries.async_update_entry( + self.config_entry, options=user_input + ) + return self.async_create_entry( + title="", + data=user_input, + ) return self.async_show_form( step_id="init", @@ -70,7 +78,15 @@ class VoipOptionsFlowHandler(OptionsFlow): CONF_SIP_PORT, SIP_PORT, ), - ): cv.port + ): cv.port, + vol.Optional( + CONF_SIP_USER, + description={ + "suggested_value": self.config_entry.options.get( + CONF_SIP_USER, None + ) + }, + ): vol.Any(None, cv.string), } ), ) diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py index b4ee5d8ce7a..9a4403f9df2 100644 --- a/homeassistant/components/voip/const.py +++ b/homeassistant/components/voip/const.py @@ -13,3 +13,4 @@ RTP_AUDIO_SETTINGS = { } CONF_SIP_PORT = "sip_port" +CONF_SIP_USER = "sip_user" diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 613d05fc614..c33ec048cbd 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator from dataclasses import dataclass, field +from typing import Any from voip_utils import CallInfo, VoipDatagramProtocol @@ -136,25 +137,56 @@ class VoIPDevices: fw_version = None dev_reg = dr.async_get(self.hass) - voip_id = call_info.caller_ip + if call_info.caller_endpoint is None: + raise RuntimeError("Could not identify VOIP caller") + voip_id = call_info.caller_endpoint.uri voip_device = self.devices.get(voip_id) - if voip_device is not None: - device = dev_reg.async_get(voip_device.device_id) - if device and fw_version and device.sw_version != fw_version: - dev_reg.async_update_device(device.id, sw_version=fw_version) + if voip_device is None: + # If we couldn't find the device based on SIP URI, see if we can + # find an old device based on just the host/IP and migrate it + old_id = call_info.caller_endpoint.host + voip_device = self.devices.get(old_id) + if voip_device is not None: + voip_device.voip_id = voip_id + self.devices[voip_id] = voip_device + dev_reg.async_update_device( + voip_device.device_id, new_identifiers={(DOMAIN, voip_id)} + ) + # Migrate entities + old_prefix = f"{old_id}-" - return voip_device + def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate entities.""" + if not entry.unique_id.startswith(old_prefix): + return None + key = entry.unique_id[len(old_prefix) :] + return { + "new_unique_id": f"{voip_id}-{key}", + } + self.config_entry.async_create_task( + self.hass, + er.async_migrate_entries( + self.hass, self.config_entry.entry_id, entity_migrator + ), + f"voip migrating entities {voip_id}", + ) + + # Update device with latest info device = dev_reg.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, voip_id)}, - name=voip_id, + name=call_info.caller_endpoint.host, manufacturer=manuf, model=model, sw_version=fw_version, configuration_url=f"http://{call_info.caller_ip}", ) + + if voip_device is not None: + return voip_device + voip_device = self.devices[voip_id] = VoIPDevice( voip_id=voip_id, device_id=device.id, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index ed7f11f8fbc..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,9 +3,9 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite"], + "dependencies": ["assist_pipeline", "assist_satellite", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.2.2"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index f145f866ae3..bfce112d0c5 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -10,7 +10,7 @@ from homeassistant.components.assist_pipeline.select import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import VoIPDevice @@ -23,7 +23,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index c25c22f3f80..96c902bf39a 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -53,7 +53,8 @@ "step": { "init": { "data": { - "sip_port": "SIP port" + "sip_port": "SIP port", + "sip_user": "SIP user" } } } diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py index f8484241fc5..7690b8f125c 100644 --- a/homeassistant/components/voip/switch.py +++ b/homeassistant/components/voip/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import VoIPDevice @@ -22,7 +22,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index c4fa7b1088b..5bd4a63c923 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -26,8 +26,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 7cc58556f3e..00b3ab911ae 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -8,12 +8,12 @@ from typing import Any from pyvolumio import CannotConnectError, Volumio import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -97,7 +97,7 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 5ba67d7974f..773a125d483 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from .browse_media import browse_node, browse_top_level @@ -33,7 +33,7 @@ PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Volumio media player platform.""" @@ -70,7 +70,6 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) - _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" @@ -78,6 +77,7 @@ class Volumio(MediaPlayerEntity): unique_id = uid self._state = {} self.thumbnail_cache = {} + self._attr_source_list = [] self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 9fc07dd92b0..1a53f9a5dc4 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: volvo_data = VolvoData(hass, connection, entry) - coordinator = VolvoUpdateCoordinator(hass, volvo_data) + coordinator = VolvoUpdateCoordinator(hass, entry, volvo_data) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py index e6104f8d87c..2ba8d19e3db 100644 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ b/homeassistant/components/volvooncall/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -24,7 +24,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure binary_sensors from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py index 5ac6a58acb0..2c3e2ba365f 100644 --- a/homeassistant/components/volvooncall/coordinator.py +++ b/homeassistant/components/volvooncall/coordinator.py @@ -3,6 +3,7 @@ import asyncio import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,12 +16,17 @@ _LOGGER = logging.getLogger(__name__) class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): """Volvo coordinator.""" - def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, volvo_data: VolvoData + ) -> None: """Initialize the data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="volvooncall", update_interval=DEFAULT_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index 96fe5a644bb..018acb02d49 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure device_trackers from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py index 6ebc4bdc754..5a1194e8b1a 100644 --- a/homeassistant/components/volvooncall/entity.py +++ b/homeassistant/components/volvooncall/entity.py @@ -57,7 +57,7 @@ class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): return f"{self._vehicle_name} {self._entity_name}" @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" return True diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index cff5df35750..75b54e9dbbc 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure locks from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index 9916d37197b..feb7248ccaf 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure sensors from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py index 7e60f47fb44..ff321577348 100644 --- a/homeassistant/components/volvooncall/switch.py +++ b/homeassistant/components/volvooncall/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure binary_sensors from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index a89b6b4a116..c2ef8b70d46 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .fetch_data import get_lessons, get_student_info @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the calendar platform for entity.""" client = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 61b5a954389..d8344cbdeec 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,7 +4,7 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." + "no_matching_entries": "No matching entries found, please use different account or remove outdated student integration." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", @@ -17,7 +17,7 @@ }, "step": { "auth": { - "description": "Login to your Vulcan Account using mobile app registration page.", + "description": "Log in to your Vulcan Account using mobile app registration page.", "data": { "token": "Token", "region": "Symbol", diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py index 36f43cf0ac0..66527bf458e 100644 --- a/homeassistant/components/vultr/__init__.py +++ b/homeassistant/components/vultr/__init__.py @@ -9,7 +9,7 @@ from vultr import Vultr as VultrAPI from homeassistant.components import persistent_notification from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py index 6a697eebe11..3972de8a625 100644 --- a/homeassistant/components/vultr/binary_sensor.py +++ b/homeassistant/components/vultr/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 843aa416297..c392c382cbd 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py index b03d613895a..0b1f2247684 100644 --- a/homeassistant/components/vultr/switch.py +++ b/homeassistant/components/vultr/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py index 62b9ba810d9..7dab0b137c5 100644 --- a/homeassistant/components/w800rf32/__init__.py +++ b/homeassistant/components/w800rf32/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index efd72c4564c..d68d950e641 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -9,7 +9,7 @@ import wakeonlan from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/wake_on_lan/button.py b/homeassistant/components/wake_on_lan/button.py index 4d6b19bdd8e..e9cf69b1fe7 100644 --- a/homeassistant/components/wake_on_lan/button.py +++ b/homeassistant/components/wake_on_lan/button.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback _LOGGER = logging.getLogger(__name__) @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Wake on LAN button entry.""" broadcast_address: str | None = entry.options.get(CONF_BROADCAST_ADDRESS) diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index fcf8936d498..16df34c1d1b 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -21,8 +21,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 8b3a5bbf331..65556668bac 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -25,12 +25,12 @@ from .const import DOMAIN from .models import DetectionResult, WakeWord __all__ = [ - "async_default_entity", - "async_get_wake_word_detection_entity", - "DetectionResult", "DOMAIN", + "DetectionResult", "WakeWord", "WakeWordDetectionEntity", + "async_default_entity", + "async_get_wake_word_detection_entity", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index b2f8ac7fd5d..fc8c6e00e84 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] @@ -27,11 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidAuth as ex: raise ConfigEntryAuthFailed from ex - wallbox_coordinator = WallboxCoordinator( - entry.data[CONF_STATION], - wallbox, - hass, - ) + wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox) await wallbox_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 99c565d9c0c..4f20f5c406d 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, Concatenate import requests from wallbox import Wallbox +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -28,6 +29,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, CHARGER_STATUS_ID_KEY, CODE_KEY, + CONF_STATION, DOMAIN, UPDATE_INTERVAL, ChargerStatus, @@ -107,14 +109,19 @@ async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None: class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" - def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, wallbox: Wallbox + ) -> None: """Initialize.""" - self._station = station + self._station = config_entry.data[CONF_STATION] self._wallbox = wallbox super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 4853a9104f2..ef35734ed7e 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -8,7 +8,7 @@ from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CHARGER_DATA_KEY, @@ -28,7 +28,9 @@ LOCK_TYPES: dict[str, LockEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox lock entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 63102646508..d217a018303 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.7.0"] + "requirements": ["wallbox==0.8.0"] } diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 24cdd16f99d..462266636d7 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -13,7 +13,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, @@ -82,7 +82,9 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 18d8afb5612..78b26520bec 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -157,7 +157,9 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 06c2674579d..30275951ab2 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CHARGER_DATA_KEY, @@ -29,7 +29,9 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index e9feca75ee7..9821b5435d9 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = WAQIClient(session=async_get_clientsession(hass)) client.authenticate(entry.data[CONF_API_KEY]) - waqi_coordinator = WAQIDataUpdateCoordinator(hass, client) + waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index d1a44e9f5b8..86f553a86cd 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -18,11 +18,14 @@ class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=5), ) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 4c921c68336..59daf60392e 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -138,7 +138,9 @@ SENSORS: list[WAQISensorEntityDescription] = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WAQI sensor.""" coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index a1feb217249..f455e3ead33 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -57,7 +57,7 @@ "name": "[%key:component::sensor::entity_component::pm25::name%]" }, "neph": { - "name": "Visbility using nephelometry" + "name": "Visibility using nephelometry" }, "dominant_pollutant": { "name": "Dominant pollutant", diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 60be340a253..f2038def79c 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -8,7 +8,7 @@ import functools as ft import logging from typing import Any, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -25,7 +25,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -72,6 +77,7 @@ ATTR_OPERATION_MODE = "operation_mode" ATTR_OPERATION_LIST = "operation_list" ATTR_TARGET_TEMP_HIGH = "target_temp_high" ATTR_TARGET_TEMP_LOW = "target_temp_low" +ATTR_TARGET_TEMP_STEP = "target_temp_step" ATTR_CURRENT_TEMPERATURE = "current_temperature" CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE] @@ -134,11 +140,11 @@ class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" -@deprecated_class("WaterHeaterEntityDescription", breaks_in_ha_version="2026.1") -class WaterHeaterEntityEntityDescription( - WaterHeaterEntityDescription, frozen_or_thawed=True -): - """A (deprecated) class that describes water heater entities.""" +_DEPRECATED_WaterHeaterEntityEntityDescription = DeprecatedConstant( + WaterHeaterEntityDescription, + "WaterHeaterEntityDescription", + breaks_in_ha_version="2026.1", +) CACHED_PROPERTIES_WITH_ATTR_ = { @@ -149,6 +155,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "target_temperature", "target_temperature_high", "target_temperature_low", + "target_temperature_step", "is_away_mode_on", } @@ -157,7 +164,12 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for water heater entities.""" _entity_component_unrecorded_attributes = frozenset( - {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} + { + ATTR_OPERATION_LIST, + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_TARGET_TEMP_STEP, + } ) entity_description: WaterHeaterEntityDescription @@ -174,6 +186,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_target_temperature_low: float | None = None _attr_target_temperature: float | None = None _attr_temperature_unit: str + _attr_target_temperature_step: float | None = None @final @property @@ -201,6 +214,8 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.hass, self.max_temp, self.temperature_unit, self.precision ), } + if target_temperature_step := self.target_temperature_step: + data[ATTR_TARGET_TEMP_STEP] = target_temperature_step if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: data[ATTR_OPERATION_LIST] = self.operation_list @@ -284,6 +299,11 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the lowbound target temperature we try to reach.""" return self._attr_target_temperature_low + @cached_property + def target_temperature_step(self) -> float | None: + """Return the supported step of target temperature.""" + return self._attr_target_temperature_step + @cached_property def is_away_mode_on(self) -> bool | None: """Return true if away mode is on.""" @@ -414,3 +434,11 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 49cfc7e9a07..d68919ff8f3 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -15,8 +15,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index fa761110339..c1747af1f11 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -16,12 +16,11 @@ from homeassistant.components.webhook import ( async_generate_url, async_register, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import WatergateDataCoordinator +from .coordinator import WatergateConfigEntry, WatergateDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,6 @@ PLATFORMS: list[Platform] = [ Platform.VALVE, ] -type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Set up Watergate from a config entry.""" @@ -52,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}" ) - coordinator = WatergateDataCoordinator(hass, watergate_client) + coordinator = WatergateDataCoordinator(hass, entry, watergate_client) entry.runtime_data = coordinator async_register( diff --git a/homeassistant/components/watergate/coordinator.py b/homeassistant/components/watergate/coordinator.py index 1d83b7a3ccb..e3f198c144d 100644 --- a/homeassistant/components/watergate/coordinator.py +++ b/homeassistant/components/watergate/coordinator.py @@ -7,6 +7,7 @@ import logging from watergate_local_api import WatergateApiException, WatergateLocalApiClient from watergate_local_api.models import DeviceState, NetworkingData, TelemetryData +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,14 +25,25 @@ class WatergateAgregatedRequests: networking: NetworkingData +type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] + + class WatergateDataCoordinator(DataUpdateCoordinator[WatergateAgregatedRequests]): """Class to manage fetching watergate data.""" - def __init__(self, hass: HomeAssistant, api: WatergateLocalApiClient) -> None: + config_entry: WatergateConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + api: WatergateLocalApiClient, + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=2), ) diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 638bf297415..5aced8b7488 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -22,12 +22,15 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import WatergateConfigEntry -from .coordinator import WatergateAgregatedRequests, WatergateDataCoordinator +from .coordinator import ( + WatergateAgregatedRequests, + WatergateConfigEntry, + WatergateDataCoordinator, +) from .entity import WatergateEntity _LOGGER = logging.getLogger(__name__) @@ -90,7 +93,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime) ) if data.networking else None @@ -104,7 +107,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ WatergateSensorEntityDescription( value_fn=lambda data: ( dt_util.as_utc( - dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime) + dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime) ) if data.networking else None @@ -158,7 +161,11 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ ), WatergateSensorEntityDescription( value_fn=lambda data: ( - PowerSupplyMode(data.state.power_supply.replace("+", "_")) + PowerSupplyMode( + data.state.power_supply.replace("+", "_").replace( + "external_battery", "battery_external" + ) + ) if data.state else None ), @@ -175,7 +182,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: WatergateConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Watergate Platform.""" diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py index 556b53e1d3c..cb6bfa7bd59 100644 --- a/homeassistant/components/watergate/valve.py +++ b/homeassistant/components/watergate/valve.py @@ -8,10 +8,9 @@ from homeassistant.components.valve import ( ValveState, ) from homeassistant.core import callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import WatergateConfigEntry -from .coordinator import WatergateDataCoordinator +from .coordinator import WatergateConfigEntry, WatergateDataCoordinator from .entity import WatergateEntity ENTITY_NAME = "valve" @@ -21,7 +20,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: WatergateConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Watergate Platform.""" diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index de8c85f5ff0..0130b53930b 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -23,8 +23,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 373d17438c9..194e0905ff0 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -10,7 +10,7 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index c6cc81580d7..d3aa9d8f895 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -52,7 +52,9 @@ REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WattTime sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 34f22c9218f..3a91690ef07 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.selector import ( BooleanSelector, SelectSelector, @@ -115,10 +116,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b client = WazeRouteCalculator( region=service.data[CONF_REGION].upper(), client=httpx_client ) + + origin_coordinates = find_coordinates(hass, service.data[CONF_ORIGIN]) + destination_coordinates = find_coordinates(hass, service.data[CONF_DESTINATION]) + + origin = origin_coordinates if origin_coordinates else service.data[CONF_ORIGIN] + destination = ( + destination_coordinates + if destination_coordinates + else service.data[CONF_DESTINATION] + ) + response = await async_get_travel_times( client=client, - origin=service.data[CONF_ORIGIN], - destination=service.data[CONF_DESTINATION], + origin=origin, + destination=destination, vehicle_type=service.data[CONF_VEHICLE_TYPE], avoid_toll_roads=service.data[CONF_AVOID_TOLL_ROADS], avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS], diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index a216a02f61e..1f21cc2ea78 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates @@ -57,7 +57,7 @@ SECONDS_BETWEEN_API_CALLS = 0.5 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Waze travel time sensor entry.""" destination = config_entry.data[CONF_DESTINATION] diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 557765795ee..e9436922a4b 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,10 +8,19 @@ from contextlib import suppress from datetime import timedelta from functools import partial import logging -from typing import Any, Final, Generic, Literal, Required, TypedDict, cast, final +from typing import ( + Any, + Final, + Generic, + Literal, + Required, + TypedDict, + TypeVar, + cast, + final, +) -from propcache import cached_property -from typing_extensions import TypeVar +from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 85d331f5bd0..31e644b32e3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -90,17 +90,17 @@ "services": { "get_forecasts": { "name": "Get forecasts", - "description": "Get weather forecasts.", + "description": "Retrieves the forecast from selected weather services.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: daily, hourly or twice daily." + "description": "The scope of the weather forecast." } } }, "get_forecast": { "name": "Get forecast", - "description": "Get weather forecast.", + "description": "Retrieves the forecast from a selected weather service.", "fields": { "type": { "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]", @@ -111,12 +111,12 @@ }, "issues": { "deprecated_service_weather_get_forecast": { - "title": "Detected use of deprecated service weather.get_forecast", + "title": "Detected use of deprecated action weather.get_forecast", "fix_flow": { "step": { "confirm": { "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", - "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue." + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue." } } } diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index cacede55c42..8eee472fe5c 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -40,7 +40,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_system import METRIC_SYSTEM @@ -267,16 +267,16 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="wind_direction", translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="wind_direction_average", translation_key="wind_direction_average", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), ) @@ -285,7 +285,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow sensors using config entry.""" diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index 8dc26f9b9c6..94c65b7c0a1 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -15,10 +15,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WeatherFlowCloud from a config entry.""" - data_coordinator = WeatherFlowCloudDataUpdateCoordinator( - hass=hass, - api_token=entry.data[CONF_API_TOKEN], - ) + data_coordinator = WeatherFlowCloudDataUpdateCoordinator(hass, entry) await data_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 8b8a916262f..b6d2bfd5af2 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -6,6 +6,8 @@ from aiohttp import ClientResponseError from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,12 +20,17 @@ class WeatherFlowCloudDataUpdateCoordinator( ): """Class to manage fetching REST Based WeatherFlow Forecast data.""" - def __init__(self, hass: HomeAssistant, api_token: str) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global WeatherFlow forecast data updater.""" - self.weather_api = WeatherFlowRestAPI(api_token=api_token) + self.weather_api = WeatherFlowRestAPI( + api_token=config_entry.data[CONF_API_TOKEN] + ) super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 98c98cfbac7..9ffa457a355 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.0.6"] + "requirements": ["weatherflow4py==1.3.1"] } diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index aeab955878f..d2c62b5f281 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -172,7 +172,7 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow sensors based on a config entry.""" diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index f707cbb0353..d22c62a030c 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Set up a WeatherFlow Forecast Station", "data": { - "api_token": "Personal api token" + "api_token": "Personal API token" } }, "reauth_confirm": { diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py index c475f2974a9..3cb1f477095 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, STATE_MAP from .coordinator import WeatherFlowCloudDataUpdateCoordinator @@ -27,7 +27,7 @@ from .entity import WeatherFlowCloudEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index 49158182696..4cbac2b32d8 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) coordinator = WeatherKitDataUpdateCoordinator( hass=hass, + config_entry=entry, client=WeatherKitApiClient( key_id=entry.data[CONF_KEY_ID], service_id=entry.data[CONF_SERVICE_ID], diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index 6438d7503db..6c7119d6fb0 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -33,6 +33,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: WeatherKitApiClient, ) -> None: """Initialize.""" @@ -40,6 +41,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=5), ) diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py index d9c17bb855a..b3639fa5356 100644 --- a/homeassistant/components/weatherkit/sensor.py +++ b/homeassistant/components/weatherkit/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolumetricFlux from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,7 +36,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensor entities from a config_entry.""" coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index 98816d520ba..b57e488d06a 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_CURRENT_WEATHER, @@ -45,7 +45,7 @@ from .entity import WeatherKitEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py new file mode 100644 index 00000000000..952a68d829f --- /dev/null +++ b/homeassistant/components/webdav/__init__.py @@ -0,0 +1,70 @@ +"""The WebDAV integration.""" + +from __future__ import annotations + +import logging + +from aiowebdav2.client import Client +from aiowebdav2.exceptions import UnauthorizedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .helpers import async_create_client, async_ensure_path_exists + +type WebDavConfigEntry = ConfigEntry[Client] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Set up WebDAV from a config entry.""" + client = async_create_client( + hass=hass, + url=entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + verify_ssl=entry.data.get(CONF_VERIFY_SSL, True), + ) + + try: + result = await client.check() + except UnauthorizedError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_username_password", + ) from err + + # Check if we can connect to the WebDAV server + # and access the root directory + if not result: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) + + # Ensure the backup directory exists + if not await async_ensure_path_exists( + client, entry.data.get(CONF_BACKUP_PATH, "/") + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_access_or_create_backup_path", + ) + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Unload a WebDAV config entry.""" + return True diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py new file mode 100644 index 00000000000..f810547022b --- /dev/null +++ b/homeassistant/components/webdav/backup.py @@ -0,0 +1,284 @@ +"""Support for WebDAV backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import logging +from typing import Any, Concatenate + +from aiohttp import ClientTimeout +from aiowebdav2 import Property, PropertyRequest +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +from propcache.api import cached_property + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import json_loads_object + +from . import WebDavConfigEntry +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +METADATA_VERSION = "1" +BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) +NAMESPACE = "https://home-assistant.io" + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[WebDavConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [WebDavBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper(self: WebDavBackupAgent, *args: P.args, **kwargs: P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except UnauthorizedError as err: + raise BackupAgentError("Authentication error") from err + except WebDavError as err: + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Backup operation failed: {err}", + ) from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +def _is_current_metadata_version(properties: list[Property]) -> bool: + """Check if any property is of the current metadata version.""" + return any( + prop.value == METADATA_VERSION + for prop in properties + if prop.namespace == NAMESPACE and prop.name == "metadata_version" + ) + + +def _backup_id_from_properties(properties: list[Property]) -> str | None: + """Return the backup ID from properties.""" + for prop in properties: + if prop.namespace == NAMESPACE and prop.name == "backup_id": + return prop.value + return None + + +class WebDavBackupAgent(BackupAgent): + """Backup agent interface.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: WebDavConfigEntry) -> None: + """Initialize the WebDAV backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @cached_property + def _backup_path(self) -> str: + """Return the path to the backup.""" + return self._entry.data.get(CONF_BACKUP_PATH, "") + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + raise BackupNotFound("Backup not found") + + return await self._client.download_iter( + f"{self._backup_path}/{suggested_filename(backup)}", + timeout=BACKUP_TIMEOUT, + ) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + (filename_tar, filename_meta) = suggested_filenames(backup) + + await self._client.upload_iter( + await open_stream(), + f"{self._backup_path}/{filename_tar}", + timeout=BACKUP_TIMEOUT, + ) + + _LOGGER.debug( + "Uploaded backup to %s", + f"{self._backup_path}/{filename_tar}", + ) + + await self._client.upload_iter( + json_dumps(backup.as_dict()), + f"{self._backup_path}/{filename_meta}", + ) + + await self._client.set_property_batch( + f"{self._backup_path}/{filename_meta}", + [ + Property( + namespace=NAMESPACE, + name="backup_id", + value=backup.backup_id, + ), + Property( + namespace=NAMESPACE, + name="metadata_version", + value=METADATA_VERSION, + ), + ], + ) + + _LOGGER.debug( + "Uploaded metadata file for %s", + f"{self._backup_path}/{filename_meta}", + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + return + + (filename_tar, filename_meta) = suggested_filenames(backup) + backup_path = f"{self._backup_path}/{filename_tar}" + + await self._client.clean(backup_path) + await self._client.clean(f"{self._backup_path}/{filename_meta}") + + _LOGGER.debug( + "Deleted backup at %s", + backup_path, + ) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + metadata_files = await self._list_metadata_files() + return [ + await self._download_metadata(metadata_file) + for metadata_file in metadata_files.values() + ] + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _list_metadata_files(self) -> dict[str, str]: + """List metadata files.""" + files = await self._client.list_with_properties( + self._backup_path, + [ + PropertyRequest( + namespace=NAMESPACE, + name="metadata_version", + ), + PropertyRequest( + namespace=NAMESPACE, + name="backup_id", + ), + ], + ) + return { + backup_id: file_name + for file_name, properties in files.items() + if file_name.endswith(".json") and _is_current_metadata_version(properties) + if (backup_id := _backup_id_from_properties(properties)) + } + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + if metadata_file := metadata_files.get(backup_id): + return await self._download_metadata(metadata_file) + + return None + + async def _download_metadata(self, path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py new file mode 100644 index 00000000000..f75544d25ad --- /dev/null +++ b/homeassistant/components/webdav/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for the WebDAV integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiowebdav2.exceptions import UnauthorizedError +import voluptuous as vol +import yarl + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_BACKUP_PATH, DOMAIN +from .helpers import async_create_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + ) + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ) + ), + vol.Optional(CONF_BACKUP_PATH, default="/"): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WebDAV.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = async_create_client( + hass=self.hass, + url=user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input.get(CONF_VERIFY_SSL, True), + ) + + # Check if we can connect to the WebDAV server + # .check() already does the most of the error handling and will return True + # if we can access the root directory + try: + result = await client.check() + except UnauthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + if result: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + parsed_url = yarl.URL(user_input[CONF_URL]) + return self.async_create_entry( + title=f"{user_input[CONF_USERNAME]}@{parsed_url.host}", + data=user_input, + ) + + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/webdav/const.py b/homeassistant/components/webdav/const.py new file mode 100644 index 00000000000..faf8ce77ca5 --- /dev/null +++ b/homeassistant/components/webdav/const.py @@ -0,0 +1,13 @@ +"""Constants for the WebDAV integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "webdav" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +CONF_BACKUP_PATH = "backup_path" diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py new file mode 100644 index 00000000000..9f91ed3bdb3 --- /dev/null +++ b/homeassistant/components/webdav/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions for the WebDAV component.""" + +from aiowebdav2.client import Client, ClientOptions + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@callback +def async_create_client( + *, + hass: HomeAssistant, + url: str, + username: str, + password: str, + verify_ssl: bool = False, +) -> Client: + """Create a WebDAV client.""" + return Client( + url=url, + username=username, + password=password, + options=ClientOptions( + verify_ssl=verify_ssl, + session=async_get_clientsession(hass), + ), + ) + + +async def async_ensure_path_exists(client: Client, path: str) -> bool: + """Ensure that a path exists recursively on the WebDAV server.""" + parts = path.strip("/").split("/") + for i in range(1, len(parts) + 1): + sub_path = "/".join(parts[:i]) + if not await client.check(sub_path) and not await client.mkdir(sub_path): + return False + + return True diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json new file mode 100644 index 00000000000..b4950bc23f3 --- /dev/null +++ b/homeassistant/components/webdav/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "webdav", + "name": "WebDAV", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webdav", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aiowebdav2"], + "quality_scale": "bronze", + "requirements": ["aiowebdav2==0.3.1"] +} diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml new file mode 100644 index 00000000000..560626fda7e --- /dev/null +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -0,0 +1,145 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: + status: done + comment: | + No known limitations. + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: + status: exempt + comment: | + No issues known to troubleshoot. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json new file mode 100644 index 00000000000..57117cdd9de --- /dev/null +++ b/homeassistant/components/webdav/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "backup_path": "Backup path", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "The URL of the WebDAV server. Check with your provider for the correct URL.", + "username": "The username for the WebDAV server.", + "password": "The password for the WebDAV server.", + "backup_path": "Define the path where the backups should be located (will be created automatically if it does not exist).", + "verify_ssl": "Whether to verify the SSL certificate of the server. If you are using a self-signed certificate, do not select this option." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "exceptions": { + "invalid_username_password": { + "message": "Invalid username or password" + }, + "cannot_connect": { + "message": "Cannot connect to WebDAV server" + }, + "cannot_access_or_create_backup_path": { + "message": "Cannot access or create backup path. Please check the path and permissions." + } + } +} diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 34e11f49978..92ef59db908 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import get_url, is_cloud_connection from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import network +from homeassistant.util import network as network_util from homeassistant.util.aiohttp import MockRequest, MockStreamReader, serialize_response _LOGGER = logging.getLogger(__name__) @@ -97,14 +97,16 @@ def async_generate_url( ) -> str: """Generate the full URL for a webhook_id.""" return ( - f"{get_url( - hass, - allow_internal=allow_internal, - allow_external=allow_external, - allow_cloud=False, - allow_ip=allow_ip, - prefer_external=prefer_external, - )}" + f"{ + get_url( + hass, + allow_internal=allow_internal, + allow_external=allow_external, + allow_cloud=False, + allow_ip=allow_ip, + prefer_external=prefer_external, + ) + }" f"{async_generate_path(webhook_id)}" ) @@ -172,7 +174,7 @@ async def async_handle_webhook( _LOGGER.debug("Unable to parse remote ip %s", request.remote) return Response(status=HTTPStatus.OK) - is_local = network.is_local(request_remote) + is_local = network_util.is_local(request_remote) if not is_local: _LOGGER.warning("Received remote request for local webhook %s", webhook_id) diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index b4fd3008cd8..907123561f7 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py index 64f8c684dfa..903d6c50a09 100644 --- a/homeassistant/components/webmin/config_flow.py +++ b/homeassistant/components/webmin/config_flow.py @@ -45,7 +45,7 @@ async def validate_user_input( raise SchemaFlowError("invalid_auth") from err raise SchemaFlowError("cannot_connect") from err except Fault as fault: - LOGGER.exception(f"Fault {fault.faultCode}: {fault.faultString}") + LOGGER.exception("Fault %s: %s", fault.faultCode, fault.faultString) raise SchemaFlowError("unknown") from fault except ClientConnectionError as err: raise SchemaFlowError("cannot_connect") from err diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py index 45261787e75..261139faf10 100644 --- a/homeassistant/components/webmin/coordinator.py +++ b/homeassistant/components/webmin/coordinator.py @@ -22,6 +22,7 @@ from .helpers import get_instance_from_options, get_sorted_mac_addresses class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The Webmin data update coordinator.""" + config_entry: ConfigEntry mac_address: str unique_id: str @@ -29,7 +30,11 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize the Webmin data update coordinator.""" super().__init__( - hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self.instance, base_url = get_instance_from_options(hass, config_entry.options) @@ -53,7 +58,6 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): (DOMAIN, format_mac(mac_address)) for mac_address in mac_addresses } else: - assert self.config_entry self.unique_id = self.config_entry.entry_id async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py index 785140393a2..a21c73bed13 100644 --- a/homeassistant/components/webmin/sensor.py +++ b/homeassistant/components/webmin/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import WebminConfigEntry @@ -200,7 +200,7 @@ def generate_filesystem_sensor_description( async def async_setup_entry( hass: HomeAssistant, entry: WebminConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Webmin sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 499d0a85518..c1a1c698f92 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -1,95 +1,53 @@ -"""Support for LG webOS Smart TV.""" +"""The LG webOS TV integration.""" from __future__ import annotations from contextlib import suppress -import logging -from typing import NamedTuple from aiowebostv import WebOsClient, WebOsTvPairError -import voluptuous as vol from homeassistant.components import notify as hass_notify -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_BUTTON, ATTR_CONFIG_ENTRY_ID, - ATTR_PAYLOAD, - ATTR_SOUND_OUTPUT, - DATA_CONFIG_ENTRY, DATA_HASS_CONFIG, DOMAIN, PLATFORMS, - SERVICE_BUTTON, - SERVICE_COMMAND, - SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) +from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) - - -class ServiceMethodDetails(NamedTuple): - """Details for SERVICE_TO_METHOD mapping.""" - - method: str - schema: vol.Schema - - -BUTTON_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_BUTTON): cv.string}) - -COMMAND_SCHEMA = CALL_SCHEMA.extend( - {vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_PAYLOAD): dict} -) - -SOUND_OUTPUT_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_SOUND_OUTPUT): cv.string}) - -SERVICE_TO_METHOD = { - SERVICE_BUTTON: ServiceMethodDetails(method="async_button", schema=BUTTON_SCHEMA), - SERVICE_COMMAND: ServiceMethodDetails( - method="async_command", schema=COMMAND_SCHEMA - ), - SERVICE_SELECT_SOUND_OUTPUT: ServiceMethodDetails( - method="async_select_sound_output", - schema=SOUND_OUTPUT_SCHEMA, - ), -} - -_LOGGER = logging.getLogger(__name__) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LG WebOS TV platform.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {}) - hass.data[DOMAIN][DATA_HASS_CONFIG] = config + """Set up the LG webOS TV platform.""" + hass.data.setdefault(DOMAIN, {DATA_HASS_CONFIG: config}) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Set the config entry up.""" host = entry.data[CONF_HOST] key = entry.data[CONF_CLIENT_SECRET] # Attempt a connection, but fail gracefully if tv is off for example. - client = WebOsClient(host, key) + entry.runtime_data = client = WebOsClient( + host, key, client_session=async_get_clientsession(hass) + ) with suppress(*WEBOSTV_EXCEPTIONS): try: await client.connect() @@ -98,20 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If pairing request accepted there will be no error # Update the stored key without triggering reauth - update_client_key(hass, entry, client) + update_client_key(hass, entry) - async def async_service_handler(service: ServiceCall) -> None: - method = SERVICE_TO_METHOD[service.service] - data = service.data.copy() - data["method"] = method.method - async_dispatcher_send(hass, DOMAIN, data) - - for service, method in SERVICE_TO_METHOD.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=method.schema - ) - - hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # set up notify platform, no entry support for notify component yet, @@ -119,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task( discovery.async_load_platform( hass, - "notify", + Platform.NOTIFY, DOMAIN, { CONF_NAME: entry.title, @@ -129,8 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - if not entry.update_listeners: - entry.async_on_unload(entry.add_update_listener(async_update_options)) + entry.async_on_unload(entry.add_update_listener(async_update_options)) async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" @@ -143,49 +88,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_control_connect(host: str, key: str | None) -> WebOsClient: - """LG Connection.""" - client = WebOsClient(host, key) - try: - await client.connect() - except WebOsTvPairError: - _LOGGER.warning("Connected to LG webOS TV %s but not paired", host) - raise - - return client - - -def update_client_key( - hass: HomeAssistant, entry: ConfigEntry, client: WebOsClient -) -> None: - """Check and update stored client key if key has changed.""" - host = entry.data[CONF_HOST] - key = entry.data[CONF_CLIENT_SECRET] - - if client.client_key != key: - _LOGGER.debug("Updating client key for host %s", host) - data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} - hass.config_entries.async_update_entry(entry, data=data) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client = entry.runtime_data await hass_notify.async_reload(hass, DOMAIN) client.clear_state_update_callbacks() await client.disconnect() - # unregister service calls, check if this is the last entry to unload - if unload_ok and not hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - for service in SERVICE_TO_METHOD: - hass.services.async_remove(DOMAIN, service) - return unload_ok diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 9a5eda7bbf7..80c8fb7f8f2 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure webostv component.""" +"""Config flow for LG webOS TV integration.""" from __future__ import annotations @@ -6,35 +6,49 @@ from collections.abc import Mapping from typing import Any, Self from urllib.parse import urlparse -from aiowebostv import WebOsTvPairError +from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol -from homeassistant.components import ssdp -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME -from homeassistant.core import callback +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) -from . import async_control_connect, update_client_key +from . import WebOsTvConfigEntry from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS -from .helpers import async_get_sources +from .helpers import get_sources DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }, extra=vol.ALLOW_EXTRA, ) +async def async_control_connect( + hass: HomeAssistant, host: str, key: str | None +) -> WebOsClient: + """Create LG webOS client and connect to the TV.""" + client = WebOsClient( + host, + key, + client_session=async_get_clientsession(hass), + ) + + await client.connect() + + return client + + class FlowHandler(ConfigFlow, domain=DOMAIN): - """WebosTV configuration flow.""" + """LG webOS TV configuration flow.""" VERSION = 1 @@ -46,7 +60,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -54,15 +68,11 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] - self._name = user_input[CONF_NAME] return await self.async_step_pairing() - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) async def async_step_pairing( self, user_input: dict[str, Any] | None = None @@ -71,39 +81,43 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self._host}) self.context["title_placeholders"] = {"name": self._name} - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: - client = await async_control_connect(self._host, None) + client = await async_control_connect(self.hass, self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: await self.async_set_unique_id( - client.hello_info["deviceUUID"], raise_on_progress=False + client.tv_info.hello["deviceUUID"], raise_on_progress=False ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + + if not self._name: + self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" assert discovery_info.ssdp_location host = urlparse(discovery_info.ssdp_location).hostname assert host self._host = host - self._name = discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME) + self._name = discovery_info.upnp.get( + ATTR_UPNP_FRIENDLY_NAME, DEFAULT_NAME + ).replace("[LG]", "LG") - uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + uuid = discovery_info.upnp[ATTR_UPNP_UDN] assert uuid - if uuid.startswith("uuid:"): - uuid = uuid[5:] + uuid = uuid.removeprefix("uuid:") await self.async_set_unique_id(uuid) self._abort_if_unique_id_configured({CONF_HOST: self._host}) @@ -128,26 +142,62 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if user_input is not None: try: - client = await async_control_connect(self._host, None) + client = await async_control_connect(self.hass, self._host, None) except WebOsTvPairError: - return self.async_abort(reason="error_pairing") + errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: - return self.async_abort(reason="reauth_unsuccessful") + errors["base"] = "cannot_connect" + else: + reauth_entry = self._get_reauth_entry() + data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reauth_entry, data=data) - reauth_entry = self._get_reauth_entry() - update_client_key(self.hass, reauth_entry, client) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_show_form(step_id="reauth_confirm", errors=errors) - return self.async_show_form(step_id="reauth_confirm") + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + host = user_input[CONF_HOST] + client_key = reconfigure_entry.data.get(CONF_CLIENT_SECRET) + + try: + client = await async_control_connect(self.hass, host, client_key) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(client.tv_info.hello["deviceUUID"]) + self._abort_if_unique_id_mismatch(reason="wrong_device") + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + return self.async_update_reload_and_abort(reconfigure_entry, data=data) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=reconfigure_entry.data.get(CONF_HOST) + ): cv.string + } + ), + errors=errors, + ) class OptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: WebOsTvConfigEntry) -> None: """Initialize options flow.""" self.host = config_entry.data[CONF_HOST] self.key = config_entry.data[CONF_CLIENT_SECRET] @@ -161,9 +211,14 @@ class OptionsFlowHandler(OptionsFlow): options_input = {CONF_SOURCES: user_input[CONF_SOURCES]} return self.async_create_entry(title="", data=options_input) # Get sources - sources_list = await async_get_sources(self.host, self.key) - if not sources_list: - errors["base"] = "cannot_retrieve" + sources_list = [] + try: + client = await async_control_connect(self.hass, self.host, self.key) + sources_list = get_sources(client.tv_state) + except WebOsTvPairError: + errors["base"] = "error_pairing" + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" option_sources = self.config_entry.options.get(CONF_SOURCES, []) sources = [s for s in option_sources if s in sources_list] diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index c20060cae91..118ea7b32db 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -1,17 +1,16 @@ -"""Constants used for LG webOS Smart TV.""" +"""Constants for the LG webOS TV integration.""" import asyncio +import aiohttp from aiowebostv import WebOsTvCommandError -from websockets.exceptions import ConnectionClosed, ConnectionClosedOK from homeassistant.const import Platform DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] -DATA_CONFIG_ENTRY = "config_entry" DATA_HASS_CONFIG = "hass_config" -DEFAULT_NAME = "LG webOS Smart TV" +DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" ATTR_CONFIG_ENTRY_ID = "entry_id" @@ -28,11 +27,11 @@ SERVICE_SELECT_SOUND_OUTPUT = "select_sound_output" LIVE_TV_APP_ID = "com.webos.app.livetv" WEBOSTV_EXCEPTIONS = ( - OSError, - ConnectionClosed, - ConnectionClosedOK, - ConnectionRefusedError, + ConnectionResetError, WebOsTvCommandError, - TimeoutError, + aiohttp.ClientConnectorError, + aiohttp.ServerDisconnectedError, + aiohttp.WSMessageTypeError, asyncio.CancelledError, + asyncio.TimeoutError, ) diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index f16b1cec4f5..951c11525b1 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device automations for control of LG webOS Smart TV.""" +"""Provides device automations for control of LG webOS TV.""" from __future__ import annotations @@ -14,8 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import trigger -from .const import DOMAIN +from . import DOMAIN, trigger from .helpers import ( async_get_client_by_device_entry, async_get_device_entry_by_device_id, @@ -43,8 +42,7 @@ async def async_validate_trigger_config( device_id = config[CONF_DEVICE_ID] try: device = async_get_device_entry_by_device_id(hass, device_id) - if DOMAIN in hass.data: - async_get_client_by_device_entry(hass, device) + async_get_client_by_device_entry(hass, device) except ValueError as err: raise InvalidDeviceAutomationConfig(err) from err @@ -77,4 +75,8 @@ async def async_attach_trigger( hass, trigger_config, action, trigger_info ) - raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unhandled_trigger_type", + translation_placeholders={"trigger_type": trigger_type}, + ) diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 1657fb71d26..e4ea38064a8 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for LG webOS Smart TV.""" +"""Diagnostics support for LG webOS TV.""" from __future__ import annotations @@ -7,11 +7,10 @@ from typing import Any from aiowebostv import WebOsClient from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import DATA_CONFIG_ENTRY, DOMAIN +from . import WebOsTvConfigEntry TO_REDACT = { CONF_CLIENT_SECRET, @@ -25,23 +24,16 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WebOsTvConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: WebOsClient = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] + client: WebOsClient = entry.runtime_data client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.current_app_id, - "current_channel": client.current_channel, - "apps": client.apps, - "inputs": client.inputs, - "system_info": client.system_info, - "software_info": client.software_info, - "hello_info": client.hello_info, - "sound_output": client.sound_output, - "is_on": client.is_on, + "tv_info": client.tv_info.__dict__, + "tv_state": client.tv_state.__dict__, } return async_redact_data( diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index edcfdcfed8b..f70f250f91d 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -1,15 +1,23 @@ -"""Helper functions for webOS Smart TV.""" +"""Helper functions for LG webOS TV.""" from __future__ import annotations -from aiowebostv import WebOsClient +import logging +from aiowebostv import WebOsClient, WebOsTvState + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import async_control_connect -from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, LIVE_TV_APP_ID + +_LOGGER = logging.getLogger(__name__) + +type WebOsTvConfigEntry = ConfigEntry[WebOsClient] @callback @@ -31,7 +39,7 @@ def async_get_device_entry_by_device_id( def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: """Get device ID from an entity ID. - Raises ValueError if entity or device ID is invalid. + Raises HomeAssistantError if entity or device ID is invalid. """ ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(entity_id) @@ -41,7 +49,11 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s or entity_entry.device_id is None or entity_entry.platform != DOMAIN ): - raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_entity_id", + translation_placeholders={"entity_id": entity_id}, + ) return entity_entry.device_id @@ -55,32 +67,32 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ for config_entry_id in device.config_entries: - if client := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry_id): - break - - if not client: - raise ValueError( - f"Device {device.id} is not from an existing {DOMAIN} config entry" + entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id ) + if entry and entry.domain == DOMAIN: + if entry.state is ConfigEntryState.LOADED: + return entry.runtime_data - return client + raise ValueError( + f"Device {device.id} is not from a loaded {DOMAIN} config entry" + ) + + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) -async def async_get_sources(host: str, key: str) -> list[str]: +def get_sources(tv_state: WebOsTvState) -> list[str]: """Construct sources list.""" - try: - client = await async_control_connect(host, key) - except WEBOSTV_EXCEPTIONS: - return [] - sources = [] found_live_tv = False - for app in client.apps.values(): + for app in tv_state.apps.values(): sources.append(app["title"]) if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - for source in client.inputs.values(): + for source in tv_state.inputs.values(): sources.append(source["label"]) if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True @@ -90,3 +102,15 @@ async def async_get_sources(host: str, key: str) -> list[str]: # Preserve order when filtering duplicates return list(dict.fromkeys(sources)) + + +def update_client_key(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: + """Check and update stored client key if key has changed.""" + client: WebOsClient = entry.runtime_data + host = entry.data[CONF_HOST] + key = entry.data[CONF_CLIENT_SECRET] + + if client.client_key != key: + _LOGGER.debug("Updating client key for host %s", host) + data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} + hass.config_entries.async_update_entry(entry, data=data) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 6c826c2f997..06cbca32453 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -1,12 +1,12 @@ { "domain": "webostv", - "name": "LG webOS Smart TV", + "name": "LG webOS TV", "codeowners": ["@thecode"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.4.2"], + "requirements": ["aiowebostv==0.7.1"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 239780e3f01..780e9f418a5 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,9 +1,9 @@ -"""Support for interface with an LG webOS Smart TV.""" +"""Support for interface with an LG webOS TV.""" from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from contextlib import suppress from datetime import timedelta from functools import wraps @@ -11,7 +11,8 @@ from http import HTTPStatus import logging from typing import Any, Concatenate, cast -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsTvPairError, WebOsTvState +import voluptuous as vol from homeassistant import util from homeassistant.components.media_player import ( @@ -21,32 +22,30 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, -) -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction +from homeassistant.helpers.typing import VolDictType -from . import update_client_key from .const import ( + ATTR_BUTTON, ATTR_PAYLOAD, ATTR_SOUND_OUTPUT, CONF_SOURCES, - DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, + SERVICE_BUTTON, + SERVICE_COMMAND, + SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_EXCEPTIONS, ) +from .helpers import WebOsTvConfigEntry, update_client_key from .triggers.turn_on import async_get_turn_on_trigger _LOGGER = logging.getLogger(__name__) @@ -68,55 +67,100 @@ SUPPORT_WEBOSTV_VOLUME = ( MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=10) +BUTTON_SCHEMA: VolDictType = {vol.Required(ATTR_BUTTON): cv.string} +COMMAND_SCHEMA: VolDictType = { + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PAYLOAD): dict, +} +SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} + +SERVICES = ( + ( + SERVICE_BUTTON, + BUTTON_SCHEMA, + "async_button", + SupportsResponse.NONE, + ), + ( + SERVICE_COMMAND, + COMMAND_SCHEMA, + "async_command", + SupportsResponse.OPTIONAL, + ), + ( + SERVICE_SELECT_SOUND_OUTPUT, + SOUND_OUTPUT_SCHEMA, + "async_select_sound_output", + SupportsResponse.OPTIONAL, + ), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: WebOsTvConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the LG webOS Smart TV platform.""" - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] - async_add_entities([LgWebOSMediaPlayerEntity(entry, client)]) + """Set up the LG webOS TV platform.""" + platform = entity_platform.async_get_current_platform() + + for service_name, schema, method, supports_response in SERVICES: + platform.async_register_entity_service( + service_name, schema, method, supports_response=supports_response + ) + + async_add_entities([LgWebOSMediaPlayerEntity(entry)]) -def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( - func: Callable[Concatenate[_T, _P], Awaitable[None]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: +def cmd[_R, **_P]( + func: Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]]: """Catch command exceptions.""" @wraps(func) - async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + async def cmd_wrapper( + self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: """Wrap all command methods.""" - try: - await func(self, *args, **kwargs) - except WEBOSTV_EXCEPTIONS as exc: - if self.state != MediaPlayerState.OFF: - raise HomeAssistantError( - f"Error calling {func.__name__} on entity {self.entity_id}," - f" state:{self.state}" - ) from exc - _LOGGER.warning( - "Error calling %s on entity %s, state:%s, error: %r", - func.__name__, - self.entity_id, - self.state, - exc, + if self.state is MediaPlayerState.OFF and func.__name__ != "async_turn_off": + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_off", + translation_placeholders={ + "name": str(self._entry.title), + "func": func.__name__, + }, ) + try: + return await func(self, *args, **kwargs) + except WEBOSTV_EXCEPTIONS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "name": str(self._entry.title), + "func": func.__name__, + "error": str(error), + }, + ) from error return cmd_wrapper class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): - """Representation of a LG webOS Smart TV.""" + """Representation of a LG webOS TV.""" _attr_device_class = MediaPlayerDeviceClass.TV _attr_has_entity_name = True _attr_name = None - def __init__(self, entry: ConfigEntry, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the webos device.""" self._entry = entry - self._client = client + self._client = entry.runtime_data self._attr_assumed_state = True self._device_name = entry.title self._attr_unique_id = entry.unique_id @@ -142,10 +186,6 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) ) - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler) - ) - await self._client.register_state_update_callback( self.async_handle_state_update ) @@ -165,64 +205,52 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_signal_handler(self, data: dict[str, Any]) -> None: - """Handle domain-specific signal by calling appropriate method.""" - if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE: - return - - if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: - params = { - key: value - for key, value in data.items() - if key not in ["entity_id", "method"] - } - await getattr(self, data["method"])(**params) - - async def async_handle_state_update(self, _client: WebOsClient) -> None: + async def async_handle_state_update(self, tv_state: WebOsTvState) -> None: """Update state from WebOsClient.""" self._update_states() self.async_write_ha_state() def _update_states(self) -> None: """Update entity state attributes.""" + tv_state = self._client.tv_state self._update_sources() self._attr_state = ( - MediaPlayerState.ON if self._client.is_on else MediaPlayerState.OFF + MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF ) - self._attr_is_volume_muted = cast(bool, self._client.muted) + self._attr_is_volume_muted = cast(bool, tv_state.muted) self._attr_volume_level = None - if self._client.volume is not None: - self._attr_volume_level = cast(float, self._client.volume / 100.0) + if tv_state.volume is not None: + self._attr_volume_level = tv_state.volume / 100.0 self._attr_source = self._current_source self._attr_source_list = sorted(self._source_list) self._attr_media_content_type = None - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._attr_media_content_type = MediaType.CHANNEL self._attr_media_title = None - if (self._client.current_app_id == LIVE_TV_APP_ID) and ( - self._client.current_channel is not None + if (tv_state.current_app_id == LIVE_TV_APP_ID) and ( + tv_state.current_channel is not None ): self._attr_media_title = cast( - str, self._client.current_channel.get("channelName") + str, tv_state.current_channel.get("channelName") ) self._attr_media_image_url = None - if self._client.current_app_id in self._client.apps: - icon: str = self._client.apps[self._client.current_app_id]["largeIcon"] + if tv_state.current_app_id in tv_state.apps: + icon: str = tv_state.apps[tv_state.current_app_id]["largeIcon"] if not icon.startswith("http"): - icon = self._client.apps[self._client.current_app_id]["icon"] + icon = tv_state.apps[tv_state.current_app_id]["icon"] self._attr_media_image_url = icon if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output in ("external_arc", "external_speaker"): + if tv_state.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME - elif self._client.sound_output != "lineout": + elif tv_state.sound_output != "lineout": supported = ( supported | SUPPORT_WEBOSTV_VOLUME @@ -238,13 +266,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) self._attr_assumed_state = True - if ( - self._client.is_on - and self._client.media_state is not None - and self._client.media_state.get("foregroundAppInfo") is not None - ): + if tv_state.is_on and tv_state.media_state: self._attr_assumed_state = False - for entry in self._client.media_state.get("foregroundAppInfo"): + for entry in tv_state.media_state: if entry.get("playState") == "playing": self._attr_state = MediaPlayerState.PLAYING elif entry.get("playState") == "paused": @@ -252,32 +276,37 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): elif entry.get("playState") == "unloaded": self._attr_state = MediaPlayerState.IDLE - if self._client.system_info is not None or self.state != MediaPlayerState.OFF: - maj_v = self._client.software_info.get("major_ver") - min_v = self._client.software_info.get("minor_ver") + tv_info = self._client.tv_info + if self.state != MediaPlayerState.OFF: + maj_v = tv_info.software.get("major_ver") + min_v = tv_info.software.get("minor_ver") if maj_v and min_v: self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" - if model := self._client.system_info.get("modelName"): + if model := tv_info.system.get("modelName"): self._attr_device_info["model"] = model + if serial_number := tv_info.system.get("serialNumber"): + self._attr_device_info["serial_number"] = serial_number + self._attr_extra_state_attributes = {} - if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: + if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { - ATTR_SOUND_OUTPUT: self._client.sound_output + ATTR_SOUND_OUTPUT: tv_state.sound_output } def _update_sources(self) -> None: """Update list of sources from current source, apps, inputs and configured list.""" + tv_state = self._client.tv_state source_list = self._source_list self._source_list = {} conf_sources = self._sources found_live_tv = False - for app in self._client.apps.values(): + for app in tv_state.apps.values(): if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - if app["id"] == self._client.current_app_id: + if app["id"] == tv_state.current_app_id: self._current_source = app["title"] self._source_list[app["title"]] = app elif ( @@ -288,10 +317,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): self._source_list[app["title"]] = app - for source in self._client.inputs.values(): + for source in tv_state.inputs.values(): if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True - if source["appId"] == self._client.current_app_id: + if source["appId"] == tv_state.current_app_id: self._current_source = source["label"] self._source_list[source["label"]] = source elif ( @@ -308,7 +337,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): # not appear in the app or input lists in some cases elif not found_live_tv: app = {"id": LIVE_TV_APP_ID, "title": "Live TV"} - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._current_source = app["title"] self._source_list["Live TV"] = app elif ( @@ -325,13 +354,13 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if self._client.is_connected(): return - with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError): + with suppress(*WEBOSTV_EXCEPTIONS): try: await self._client.connect() except WebOsTvPairError: self._entry.async_start_reauth(self.hass) else: - update_client_key(self.hass, self._entry, self._client) + update_client_key(self.hass, self._entry) @property def supported_features(self) -> MediaPlayerEntityFeature: @@ -372,9 +401,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.set_mute(mute) @cmd - async def async_select_sound_output(self, sound_output: str) -> None: + async def async_select_sound_output(self, sound_output: str) -> ServiceResponse: """Select the sound output.""" - await self._client.change_sound_output(sound_output) + return await self._client.change_sound_output(sound_output) @cmd async def async_media_play_pause(self) -> None: @@ -388,10 +417,14 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" if (source_dict := self._source_list.get(source)) is None: - _LOGGER.warning( - "Source %s not found for %s", source, self._friendly_name_internal() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="source_not_found", + translation_placeholders={ + "source": source, + "name": str(self._friendly_name_internal()), + }, ) - return if source_dict.get("title"): await self._client.launch_app(source_dict["id"]) elif source_dict.get("label"): @@ -404,12 +437,12 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) - if media_type == MediaType.CHANNEL: + if media_type == MediaType.CHANNEL and self._client.tv_state.channels: _LOGGER.debug("Searching channel") partial_match_channel_id = None perfect_match_channel_id = None - for channel in self._client.channels: + for channel in self._client.tv_state.channels: if media_id == channel["channelNumber"]: perfect_match_channel_id = channel["channelId"] continue @@ -454,7 +487,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_next_track(self) -> None: """Send next track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_up() else: await self._client.fast_forward() @@ -462,7 +495,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_previous_track(self) -> None: """Send the previous track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_down() else: await self._client.rewind() @@ -473,9 +506,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.button(button) @cmd - async def async_command(self, command: str, **kwargs: Any) -> None: + async def async_command(self, command: str, **kwargs: Any) -> ServiceResponse: """Send a command.""" - await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) + return await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]: """Retrieve an image. diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 43320687ce8..3966cea5e92 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -1,20 +1,21 @@ -"""Support for LG WebOS TV notification service.""" +"""Support for LG webOS TV notification service.""" from __future__ import annotations -import logging from typing import Any -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCEPTIONS +from . import WebOsTvConfigEntry +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS -_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 async def async_get_service( @@ -27,30 +28,53 @@ async def async_get_service( if discovery_info is None: return None - client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][discovery_info[ATTR_CONFIG_ENTRY_ID]] + config_entry = hass.config_entries.async_get_entry( + discovery_info[ATTR_CONFIG_ENTRY_ID] + ) + assert config_entry is not None - return LgWebOSNotificationService(client) + return LgWebOSNotificationService(config_entry) class LgWebOSNotificationService(BaseNotificationService): - """Implement the notification service for LG WebOS TV.""" + """Implement the notification service for LG webOS TV.""" - def __init__(self, client: WebOsClient) -> None: + def __init__(self, entry: WebOsTvConfigEntry) -> None: """Initialize the service.""" - self._client = client + self._entry = entry async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the tv.""" - try: - if not self._client.is_connected(): - await self._client.connect() + client: WebOsClient = self._entry.runtime_data + data = kwargs[ATTR_DATA] + icon_path = data.get(ATTR_ICON) if data else None - data = kwargs[ATTR_DATA] - icon_path = data.get(ATTR_ICON) if data else None - await self._client.send_message(message, icon_path=icon_path) - except WebOsTvPairError: - _LOGGER.error("Pairing with TV failed") - except FileNotFoundError: - _LOGGER.error("Icon %s not found", icon_path) - except WEBOSTV_EXCEPTIONS: - _LOGGER.error("TV unreachable") + if not client.tv_state.is_on: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_device_off", + translation_placeholders={ + "name": str(self._entry.title), + "func": __name__, + }, + ) + try: + await client.send_message(message, icon_path=icon_path) + except FileNotFoundError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_icon_not_found", + translation_placeholders={ + "name": str(self._entry.title), + "icon_path": str(icon_path), + }, + ) from error + except WEBOSTV_EXCEPTIONS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="notify_communication_error", + translation_placeholders={ + "name": str(self._entry.title), + "error": str(error), + }, + ) from error diff --git a/homeassistant/components/webostv/quality_scale.yaml b/homeassistant/components/webostv/quality_scale.yaml index b6a6a5e99a4..70f845404cd 100644 --- a/homeassistant/components/webostv/quality_scale.yaml +++ b/homeassistant/components/webostv/quality_scale.yaml @@ -1,56 +1,50 @@ rules: # Bronze - action-setup: - status: todo - comment: move actions to entity services + action-setup: done appropriate-polling: done brands: done common-modules: status: exempt comment: The integration does not use common patterns. - config-flow-test-coverage: todo - config-flow: - status: todo - comment: remove duplicated config flow start in tests, make sure tests ends with CREATE_ENTRY or ABORT, remove name parameter, use hass.config_entries.async_setup instead of async_setup_component, snapshot in diagnostics (and other tests when possible), test_client_disconnected validate no error in log, make reauth flow more graceful + config-flow-test-coverage: done + config-flow: done dependency-transparency: done - docs-actions: - status: todo - comment: add description for parameters + docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: The integration connects to a single device. @@ -64,11 +58,11 @@ rules: entity-translations: status: exempt comment: There are no entities to translate. - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: The only entity can use the device class. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: The integration does not have anything to repair. @@ -78,9 +72,5 @@ rules: # Platinum async-dependency: done - inject-websession: - status: todo - comment: need to check if it is needed for websockets or migrate to aiohttp - strict-typing: - status: todo - comment: aiowebostv is not fully typed + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 3ceab5f50a3..f6d033af632 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -1,49 +1,61 @@ { "config": { - "flow_title": "LG webOS Smart TV", + "flow_title": "{name}", "step": { "user": { - "description": "Turn on TV, fill the following fields and select **Submit**", + "description": "Turn on the TV, fill the host field and select **Submit**", "data": { - "host": "[%key:common::config_flow::data::host%]", - "name": "[%key:common::config_flow::data::name%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your webOS TV." + "host": "Hostname or IP address of your LG webOS TV." } }, "pairing": { - "title": "webOS TV Pairing", + "title": "LG webOS TV Pairing", "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", "description": "[%key:component::webostv::config::step::pairing::description%]" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::webostv::config::step::user::data_description::host%]" + } } }, "error": { - "cannot_connect": "Failed to connect, please turn on your TV or check the IP address" + "cannot_connect": "Failed to connect, please turn on your TV and try again.", + "error_pairing": "Pairing failed, make sure to accept the pairing request on the TV and try again." }, "abort": { - "error_pairing": "Connected to LG webOS TV but not paired", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please turn on your TV and try again." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The configured device is not the same found on this Hostname or IP address." } }, "options": { "step": { "init": { - "title": "Options for webOS Smart TV", + "title": "Options for LG webOS TV", "description": "Select enabled sources", "data": { "sources": "Sources list" + }, + "data_description": { + "sources": "List of sources to enable" } } }, "error": { - "cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on" + "cannot_connect": "[%key:component::webostv::config::error::cannot_connect%]", + "error_pairing": "[%key:component::webostv::config::error::error_pairing%]" } }, "device_automation": { @@ -98,5 +110,34 @@ } } } + }, + "exceptions": { + "device_off": { + "message": "Error calling {func} for device {name}: Device is off and cannot be controlled." + }, + "communication_error": { + "message": "Communication error while calling {func} for device {name}: {error}" + }, + "notify_device_off": { + "message": "Error sending notification to device {name}: Device is off and cannot be controlled." + }, + "notify_icon_not_found": { + "message": "Icon {icon_path} not found when sending notification for device {name}" + }, + "notify_communication_error": { + "message": "Communication error while sending notification to device {name}: {error}" + }, + "unhandled_trigger_type": { + "message": "Unhandled trigger type: {trigger_type}" + }, + "unknown_trigger_platform": { + "message": "Unknown trigger platform: {platform}" + }, + "invalid_entity_id": { + "message": "Entity {entity_id} is not a valid webostv entity." + }, + "source_not_found": { + "message": "Source {source} not found in the sources list for {name}." + } } } diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 3290aa4a448..f121daafb91 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -1,4 +1,4 @@ -"""webOS Smart TV trigger dispatcher.""" +"""LG webOS TV trigger dispatcher.""" from __future__ import annotations @@ -6,6 +6,7 @@ from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import ( TriggerActionType, TriggerInfo, @@ -13,6 +14,7 @@ from homeassistant.helpers.trigger import ( ) from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .triggers import turn_on TRIGGERS = { @@ -24,8 +26,10 @@ def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: - raise ValueError( - f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_trigger_platform", + translation_placeholders={"platform": config[CONF_PLATFORM]}, ) return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py index d8c5a28ef3f..89bdf5f90ee 100644 --- a/homeassistant/components/webostv/triggers/__init__.py +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -1 +1 @@ -"""webOS Smart TV triggers.""" +"""LG webOS TV triggers.""" diff --git a/homeassistant/components/webostv/triggers/turn_on.py b/homeassistant/components/webostv/triggers/turn_on.py index f2ecb8aa98d..648da690715 100644 --- a/homeassistant/components/webostv/triggers/turn_on.py +++ b/homeassistant/components/webostv/triggers/turn_on.py @@ -1,4 +1,4 @@ -"""webOS Smart TV device turn on trigger.""" +"""LG webOS TV device turn on trigger.""" from __future__ import annotations diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cfa132b71eb..4a360b4a43c 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -275,10 +275,10 @@ async def handle_call_service( translation_domain=const.DOMAIN, translation_key="child_service_not_found", translation_placeholders={ - "domain": err.domain, - "service": err.service, - "child_domain": msg["domain"], - "child_service": msg["service"], + "domain": msg["domain"], + "service": msg["service"], + "child_domain": err.domain, + "child_service": err.service, }, ) except vol.Invalid as err: diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 62f1adc39b9..12473c86255 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -40,17 +40,17 @@ class ActiveConnection: """Handle an active websocket client connection.""" __slots__ = ( - "logger", - "hass", - "send_message", - "user", - "refresh_token_id", - "subscriptions", - "last_id", - "can_coalesce", - "supported_features", - "handlers", "binary_handlers", + "can_coalesce", + "handlers", + "hass", + "last_id", + "logger", + "refresh_token_id", + "send_message", + "subscriptions", + "supported_features", + "user", ) def __init__( @@ -189,13 +189,13 @@ class ActiveConnection: if ( # Not using isinstance as we don't care about children # as these are always coming from JSON - type(msg) is not dict # noqa: E721 + type(msg) is not dict or ( not (cur_id := msg.get("id")) - or type(cur_id) is not int # noqa: E721 + or type(cur_id) is not int or cur_id < 0 or not (type_ := msg.get("type")) - or type(type_) is not str # noqa: E721 + or type(type_) is not str ) ): self.logger.error("Received invalid command: %s", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index e7d57aebab6..ebca497193b 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -63,27 +63,27 @@ class WebSocketAdapter(logging.LoggerAdapter): def process(self, msg: str, kwargs: Any) -> tuple[str, Any]: """Add connid to websocket log messages.""" assert self.extra is not None - return f'[{self.extra["connid"]}] {msg}', kwargs + return f"[{self.extra['connid']}] {msg}", kwargs class WebSocketHandler: """Handle an active websocket client connection.""" __slots__ = ( - "_hass", - "_loop", - "_request", - "_wsock", - "_handle_task", - "_writer_task", - "_closing", "_authenticated", - "_logger", - "_peak_checker_unsub", + "_closing", "_connection", + "_handle_task", + "_hass", + "_logger", + "_loop", "_message_queue", + "_peak_checker_unsub", "_ready_future", "_release_ready_queue_size", + "_request", + "_writer_task", + "_wsock", ) def __init__(self, hass: HomeAssistant, request: web.Request) -> None: @@ -197,7 +197,7 @@ class WebSocketHandler: # max pending messages. return - if type(message) is not bytes: # noqa: E721 + if type(message) is not bytes: if isinstance(message, dict): message = message_to_json_bytes(message) elif isinstance(message, str): @@ -387,7 +387,14 @@ class WebSocketHandler: raise Disconnect("Received close message during auth phase") if msg.type is not WSMsgType.TEXT: - raise Disconnect("Received non-Text message during auth phase") + if msg.type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during auth phase: {msg.data}" + ) + raise Disconnect( + f"Received non-Text message of type {msg.type} during auth phase" + ) try: auth_msg_data = json_loads(msg.data) @@ -477,7 +484,12 @@ class WebSocketHandler: continue if msg_type is not WSMsgType.TEXT: - raise Disconnect("Received non-Text message.") + if msg_type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during command phase: {msg.data}" + ) + raise Disconnect(f"Received non-Text message of type {msg_type}.") try: command_msg_data = json_loads(msg_data) @@ -490,7 +502,7 @@ class WebSocketHandler: ) # command_msg_data is always deserialized from JSON as a list - if type(command_msg_data) is not list: # noqa: E721 + if type(command_msg_data) is not list: async_handle_str(command_msg_data) continue diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index a043a3a6845..b67c3540dc5 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,25 +2,26 @@ from __future__ import annotations +from http import HTTPStatus + +import aiohttp from weheat.abstractions.discovery import HeatPumpDiscovery from weheat.exceptions import UnauthorizedException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) from .const import API_URL, LOGGER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool: """Set up Weheat from a config entry.""" @@ -28,19 +29,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + LOGGER.warning("API error: %s (%s)", ex.status, ex.message) + if ex.status in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + token = session.token[CONF_ACCESS_TOKEN] entry.runtime_data = [] # fetch a list of the heat pumps the entry can access try: - discovered_heat_pumps = await HeatPumpDiscovery.discover_active(API_URL, token) + discovered_heat_pumps = await HeatPumpDiscovery.async_discover_active( + API_URL, token, async_get_clientsession(hass) + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, session, pump_info) + new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) await new_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/weheat/api.py b/homeassistant/components/weheat/api.py deleted file mode 100644 index b1f5c0b3eff..00000000000 --- a/homeassistant/components/weheat/api.py +++ /dev/null @@ -1,28 +0,0 @@ -"""API for Weheat bound to Home Assistant OAuth.""" - -from aiohttp import ClientSession -from weheat.abstractions import AbstractAuth - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - -from .const import API_URL - - -class AsyncConfigEntryAuth(AbstractAuth): - """Provide Weheat authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize Weheat auth.""" - super().__init__(websession, host=API_URL) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return self._oauth_session.token[CONF_ACCESS_TOKEN] diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index ea939227e77..6a4a03a1e48 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -11,13 +11,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WeheatConfigEntry -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -62,7 +64,7 @@ BINARY_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: WeheatConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index b1a0b5dd4ea..2911ebdd49b 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -4,10 +4,11 @@ from collections.abc import Mapping import logging from typing import Any -from weheat.abstractions.user import get_user_id_from_token +from weheat.abstractions.user import async_get_user_id_from_token from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES @@ -33,8 +34,10 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Override the create entry method to change to the step to find the heat pumps.""" # get the user id and use that as unique id for this entry - user_id = await get_user_id_from_token( - API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] + user_id = await async_get_user_id_from_token( + API_URL, + data[CONF_TOKEN][CONF_ACCESS_TOKEN], + async_get_clientsession(self.hass), ) await self.async_set_unique_id(user_id) if self.source != SOURCE_REAUTH: diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index a50e9daec18..d7e53258e9b 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -13,9 +13,11 @@ from weheat.exceptions import ( UnauthorizedException, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,13 +31,18 @@ EXCEPTIONS = ( ApiException, ) +type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """A custom coordinator for the Weheat heatpump integration.""" + config_entry: WeheatConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, ) -> None: @@ -43,11 +50,14 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) self.heat_pump_info = heat_pump - self._heat_pump_data = HeatPump(API_URL, heat_pump.uuid) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) self.session = session @@ -68,19 +78,17 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """Return the model of the heat pump.""" return self.heat_pump_info.model - def fetch_data(self) -> HeatPump: - """Get the data from the API.""" + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + try: - self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN]) + await self._heat_pump_data.async_get_status( + self.session.token[CONF_ACCESS_TOKEN] + ) except UnauthorizedException as error: raise ConfigEntryAuthFailed from error except EXCEPTIONS as error: raise UpdateFailed(error) from error return self._heat_pump_data - - async def _async_update_data(self) -> HeatPump: - """Fetch data from the API.""" - await self.session.async_ensure_token_valid() - - return await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 1c6242de29c..a408303d062 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.12.22"] + "requirements": ["weheat==2025.2.22"] } diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml new file mode 100644 index 00000000000..705efce4421 --- /dev/null +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions currently available + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: todo + comment: | + There are two servers that are used for this integration. + If the authentication server is unreachable, the user will not pass the configuration step. + If the backend is unreachable, an empty error message is displayed. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions currently available + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters available. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and thus does not support discovery. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + There is no reconfiguration, as the only configuration step is authentication. + repair-issues: + status: exempt + comment: | + This is a cloud service and apart form reauthentication there are not user repairable issues. + stale-devices: + status: todo + comment: | + While unlikely to happen. Check if it is easily integrated. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 3e5d9376c34..615bfd30d18 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -19,18 +19,20 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WeheatConfigEntry from .const import ( DISPLAY_PRECISION_COP, DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class WeHeatSensorEntityDescription(SensorEntityDescription): @@ -198,7 +200,7 @@ DHW_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: WeheatConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 2a208c2f8ca..3959acad053 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -37,7 +37,7 @@ "name": "Indoor unit water pump" }, "indoor_unit_auxiliary_pump_state": { - "name": "Indoor unit auxilary water pump" + "name": "Indoor unit auxiliary water pump" }, "indoor_unit_dhw_valve_or_pump_state": { "name": "Indoor unit DHW valve or water pump" diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index f2bcb04d96f..4ed361b18ba 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -5,7 +5,7 @@ from pywemo import Insight, Maker, StandbyState from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import async_wemo_dispatcher_connect from .coordinator import DeviceCoordinator @@ -15,7 +15,7 @@ from .entity import WemoBinaryStateEntity, WemoEntity async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 1f25c12f7ca..0aaedf598d2 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -88,13 +88,17 @@ class Options: class DeviceCoordinator(DataUpdateCoordinator[None]): """Home Assistant wrapper for a pyWeMo device.""" + config_entry: ConfigEntry options: Options | None = None - def __init__(self, hass: HomeAssistant, wemo: WeMoDevice) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice + ) -> None: """Initialize DeviceCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=wemo.name, update_interval=timedelta(seconds=30), ) @@ -285,7 +289,7 @@ async def async_register_device( hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice ) -> DeviceCoordinator: """Register a device with home assistant and enable pywemo event callbacks.""" - device = DeviceCoordinator(hass, wemo) + device = DeviceCoordinator(hass, config_entry, wemo) await device.async_refresh() if not device.last_update_success and device.last_exception: raise device.last_exception diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 42dae679aa5..edfdfc1c78c 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -13,7 +13,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -48,7 +48,7 @@ SET_HUMIDITY_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 6068cd3ff0b..838073be84a 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -20,8 +20,8 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN @@ -35,7 +35,7 @@ WEMO_OFF = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo lights.""" @@ -53,7 +53,7 @@ async def async_setup_entry( def async_setup_bridge( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: DeviceCoordinator, ) -> None: """Set up a WeMo link.""" diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 90e3546eaf7..76a0265d7da 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import async_wemo_dispatcher_connect @@ -59,7 +59,7 @@ ATTRIBUTE_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo sensors.""" diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 3f7bb08b704..7b87b3147d0 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import async_wemo_dispatcher_connect from .coordinator import DeviceCoordinator @@ -36,7 +36,7 @@ MAKER_SWITCH_TOGGLE = "toggle" async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo switches.""" diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 943c5d1c956..6baf738e54e 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry from .const import DOMAIN @@ -70,7 +70,7 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: WhirlpoolConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" whirlpool_data = config_entry.runtime_data diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 069a5ca1e4f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,13 +9,12 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN @@ -40,31 +39,41 @@ REAUTH_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: - """Validate the user input allows us to connect. +async def authenticate( + hass: HomeAssistant, data: dict[str, str], check_appliances_exist: bool +) -> str | None: + """Authenticate with the api. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Returns the error translation key if authentication fails, or None on success. """ session = async_get_clientsession(hass) region = CONF_REGIONS_MAP[data[CONF_REGION]] brand = CONF_BRANDS_MAP[data[CONF_BRAND]] backend_selector = BackendSelector(brand, region) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) + try: await auth.do_auth() - except (TimeoutError, ClientError) as exc: - raise CannotConnect from exc + except WhirlpoolAccountLocked: + return "account_locked" + except (TimeoutError, ClientError): + return "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + return "unknown" if not auth.is_access_token_valid(): - raise InvalidAuth + return "invalid_auth" - appliances_manager = AppliancesManager(backend_selector, auth, session) - await appliances_manager.fetch_appliances() + if check_appliances_exist: + appliances_manager = AppliancesManager(backend_selector, auth, session) + await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: - raise NoAppliances + if not appliances_manager.aircons and not appliances_manager.washer_dryers: + return "no_appliances" - return {"title": data[CONF_USERNAME]} + return None class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): @@ -90,14 +99,10 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): brand = user_input[CONF_BRAND] data = {**reauth_entry.data, CONF_PASSWORD: password, CONF_BRAND: brand} - try: - await validate_input(self.hass, data) - except InvalidAuth: - errors["base"] = "invalid_auth" - except (CannotConnect, TimeoutError): - errors["base"] = "cannot_connect" - else: + error_key = await authenticate(self.hass, data, False) + if not error_key: return self.async_update_reload_and_abort(reauth_entry, data=data) + errors["base"] = error_key return self.async_show_form( step_id="reauth_confirm", @@ -113,38 +118,17 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoAppliances: - errors["base"] = "no_appliances" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + error_key = await authenticate(self.hass, user_input, True) + if not error_key: await self.async_set_unique_id( user_input[CONF_USERNAME].lower(), raise_on_progress=False ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + errors = {"base": error_key} return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoAppliances(HomeAssistantError): - """Error to indicate no supported appliances in the user account.""" diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index b463a1a76f8..67901eea482 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.11"] + "requirements": ["whirlpool-sixth-sense==0.18.12"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index b84518cedf1..f4811feb2c9 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -132,7 +132,7 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: WhirlpoolConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whrilpool Laundry.""" entities: list = [] @@ -291,9 +291,8 @@ class WasherDryerTimeClass(RestoreSensor): seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if ( - self._attr_native_value is None - or isinstance(self._attr_native_value, datetime) + if self._attr_native_value is None or ( + isinstance(self._attr_native_value, datetime) and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) ): self._attr_native_value = new_timestamp diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_error%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index fe193b16eea..8098e052575 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -127,7 +127,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" coordinator: DataUpdateCoordinator[Domain | None] = hass.data[DOMAIN][ diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index b7431b2555c..93fdb7cce1c 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -4,7 +4,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CREATE_ENTITY_SIGNAL from .entity import WiffiEntity @@ -13,7 +13,7 @@ from .entity import WiffiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform for a new integration. diff --git a/homeassistant/components/wiffi/entity.py b/homeassistant/components/wiffi/entity.py index fd774c930c8..84bbc9b3df1 100644 --- a/homeassistant/components/wiffi/entity.py +++ b/homeassistant/components/wiffi/entity.py @@ -41,7 +41,7 @@ class WiffiEntity(Entity): self._value = None self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 699a760685a..9afcc719c9b 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, LIGHT_LUX, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CREATE_ENTITY_SIGNAL from .entity import WiffiEntity @@ -41,7 +41,7 @@ UOM_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform for a new integration. diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 74663d61d8f..1036e5b1ead 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -5,9 +5,15 @@ from urllib.parse import urlparse import pywilight -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .const import DOMAIN @@ -53,25 +59,25 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._title, data=data) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle a discovered WiLight.""" # Filter out basic information if ( not discovery_info.ssdp_location - or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info.upnp - or ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info.upnp - or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp + or ATTR_UPNP_MANUFACTURER not in discovery_info.upnp + or ATTR_UPNP_SERIAL not in discovery_info.upnp + or ATTR_UPNP_MODEL_NAME not in discovery_info.upnp + or ATTR_UPNP_MODEL_NUMBER not in discovery_info.upnp ): return self.async_abort(reason="not_wilight_device") # Filter out non-WiLight devices - if discovery_info.upnp[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: + if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: return self.async_abort(reason="not_wilight_device") host = urlparse(discovery_info.ssdp_location).hostname - serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] - model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] + model_name = discovery_info.upnp[ATTR_UPNP_MODEL_NAME] if not self._wilight_update(host, serial_number, model_name): return self.async_abort(reason="not_wilight_device") diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index 8a5cb45d909..2e9b92e7a21 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -18,7 +18,7 @@ from pywilight.const import ( from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WiLightDevice @@ -26,7 +26,9 @@ from .parent_device import WiLightParent async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight covers from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index a14198e3b5d..6a22da5879e 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -19,7 +19,7 @@ from pywilight.wilight_device import PyWiLightDevice from homeassistant.components.fan import DIRECTION_FORWARD, FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -33,7 +33,9 @@ ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight lights from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index fbe2499798d..7df0eb1a4c6 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WiLightDevice @@ -41,7 +41,9 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightE async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight lights from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index f2a1ce8b0c5..148ea65dd94 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WiLightDevice @@ -75,7 +75,9 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> tuple[Any]: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight switches from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index a32e940073b..806e7abed00 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -10,7 +10,7 @@ from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 9e8075dd874..8a0957e16e3 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py index 31f8ee99d0d..73b13cdc397 100644 --- a/homeassistant/components/wirelesstag/entity.py +++ b/homeassistant/components/wirelesstag/entity.py @@ -60,11 +60,11 @@ class WirelessTagBaseSensor(Entity): return f"{value:.1f}" @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._tag.is_alive - def update(self): + def update(self) -> None: """Update state.""" if not self.should_poll: return diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 7a3cbe5efe2..9b92480ecf9 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index cae5d63988c..9fa630d4f55 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c196bd4b92..1392b72f16b 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -16,6 +16,7 @@ from aiohttp import ClientError from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient +from aiowithings.exceptions import WithingsError from aiowithings.util import to_enum from yarl import URL @@ -119,13 +120,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> client.refresh_token_function = _refresh_token withings_data = WithingsData( client=client, - measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), - sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), - bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), - goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), - activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), - workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), - device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, client), + measurement_coordinator=WithingsMeasurementDataUpdateCoordinator( + hass, entry, client + ), + sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, entry, client), + bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator( + hass, entry, client + ), + goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, entry, client), + activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, entry, client), + workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, entry, client), + device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, entry, client), ) for coordinator in withings_data.coordinators: @@ -223,10 +228,13 @@ class WithingsWebhookManager: "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] ) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) - await async_unsubscribe_webhooks(self.withings_data.client) for coordinator in self.withings_data.coordinators: coordinator.webhook_subscription_listener(False) self._webhooks_registered = False + try: + await async_unsubscribe_webhooks(self.withings_data.client) + except WithingsError as ex: + LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex) async def register_webhook( self, diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 691026ccb9a..457bbe59bcc 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WithingsConfigEntry from .const import DOMAIN @@ -22,7 +22,7 @@ from .entity import WithingsEntity async def async_setup_entry( hass: HomeAssistant, entry: WithingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" coordinator = entry.runtime_data.bed_presence_coordinator diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index acab0fa5c40..8dcad9d73ba 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -10,8 +10,8 @@ from aiowithings import WithingsClient, WorkoutCategory from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, WithingsConfigEntry from .coordinator import WithingsWorkoutDataUpdateCoordinator @@ -21,7 +21,7 @@ from .entity import WithingsEntity async def async_setup_entry( hass: HomeAssistant, entry: WithingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the calendar platform for entity.""" ent_reg = er.async_get(hass) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 79419ae23ff..13789816d85 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -44,11 +44,17 @@ class WithingsDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): webhooks_connected: bool = False coordinator_name: str = "" - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="", update_interval=self._default_update_interval, ) @@ -95,9 +101,14 @@ class WithingsMeasurementDataUpdateCoordinator( coordinator_name: str = "measurements" - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.WEIGHT, NotificationCategory.PRESSURE, @@ -133,9 +144,14 @@ class WithingsSleepDataUpdateCoordinator( coordinator_name: str = "sleep" - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.SLEEP, } @@ -184,9 +200,14 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non in_bed: bool | None = None _default_update_interval = None - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.IN_BED, NotificationCategory.OUT_BED, @@ -226,9 +247,14 @@ class WithingsActivityDataUpdateCoordinator( coordinator_name: str = "activity" _previous_data: Activity | None = None - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.ACTIVITY, } @@ -265,9 +291,14 @@ class WithingsWorkoutDataUpdateCoordinator( coordinator_name: str = "workout" _previous_data: Workout | None = None - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.ACTIVITY, } diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index ad9b9a6fe71..232997da054 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.4"] + "requirements": ["aiowithings==3.1.6"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1005b5995a5..28a0fbd1492 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -425,6 +425,9 @@ SLEEP_SENSORS = [ key="sleep_snoring", value_fn=lambda sleep_summary: sleep_summary.snoring, translation_key="snoring", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -683,7 +686,7 @@ def get_current_goals(goals: Goals) -> set[str]: async def async_setup_entry( hass: HomeAssistant, entry: WithingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" ent_reg = er.async_get(hass) diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py index 3411ee200b9..385e6827d77 100644 --- a/homeassistant/components/wiz/binary_sensor.py +++ b/homeassistant/components/wiz/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .const import DOMAIN, SIGNAL_WIZ_PIR @@ -27,7 +27,7 @@ OCCUPANCY_UNIQUE_ID = "{}_occupancy" async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WiZ binary sensor platform.""" mac = entry.runtime_data.bulb.mac diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 71bc0a9aaa8..92b25389450 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -10,10 +10,11 @@ from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant.components import dhcp, onboarding +from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util.network import is_ip_address from .const import DEFAULT_NAME, DISCOVER_SCAN_TIMEOUT, DOMAIN, WIZ_CONNECT_EXCEPTIONS @@ -38,7 +39,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, DiscoveredBulb] = {} async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery via dhcp.""" self._discovered_device = DiscoveredBulb( diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 9ef4cd57b3d..e38d518f6bc 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizToggleEntity @@ -57,7 +57,7 @@ def _async_pilot_builder(**kwargs: Any) -> PilotBuilder: async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WiZ Platform from config_flow.""" if entry.runtime_data.bulb.bulbtype.bulb_type != BulbClass.SOCKET: diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index 0591e854d7d..0c8ee3f2bf4 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizEntity @@ -68,7 +68,7 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the wiz speed number.""" async_add_entities( diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index eb77686a5cf..217dae9e8fb 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizEntity @@ -45,7 +45,7 @@ POWER_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the wiz sensor.""" entities = [ diff --git a/homeassistant/components/wiz/switch.py b/homeassistant/components/wiz/switch.py index 4c089d2d6d2..a57834bc18d 100644 --- a/homeassistant/components/wiz/switch.py +++ b/homeassistant/components/wiz/switch.py @@ -9,7 +9,7 @@ from pywizlight.bulblibrary import BulbClass from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizToggleEntity @@ -19,7 +19,7 @@ from .models import WizData async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WiZ switch platform.""" if entry.runtime_data.bulb.bulbtype.bulb_type == BulbClass.SOCKET: diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 74799b4dcc4..119b2dc9b9f 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator @@ -16,7 +16,7 @@ from .helpers import wled_exception_handler async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED button based on a config entry.""" async_add_entities([WLEDRestartButton(entry.runtime_data)]) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 812a0500d1a..2e0b7b1c793 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from wled import WLED, Device, WLEDConnectionError -from homeassistant.components import onboarding, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN @@ -68,7 +69,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" # Abort quick if the mac address is provided by discovery info diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index b4edf10dc58..5e2ff117580 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .const import ( @@ -39,7 +39,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED light based on a config entry.""" coordinator = entry.runtime_data @@ -284,7 +284,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 225d783bfdb..e4ff184fd4b 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -11,7 +11,7 @@ from wled import Segment from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .const import ATTR_INTENSITY, ATTR_SPEED @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED number based on a config entry.""" coordinator = entry.runtime_data @@ -130,7 +130,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index a645b04573c..e340c323151 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -9,7 +9,7 @@ from wled import LiveDataOverride from homeassistant.components.select import SelectEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED select based on a config entry.""" coordinator = entry.runtime_data @@ -191,7 +191,7 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 4f97c367612..06f96782019 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -128,7 +128,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 643834dcdec..8ed6ed56114 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .const import ATTR_DURATION, ATTR_TARGET_BRIGHTNESS, ATTR_UDP_PORT @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED switch based on a config entry.""" coordinator = entry.runtime_data @@ -195,7 +195,7 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index 384b394ac50..ccf72425b77 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -10,7 +10,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLED_KEY, WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator @@ -21,7 +21,7 @@ from .helpers import wled_exception_handler async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED update based on a config entry.""" async_add_entities([WLEDUpdateEntity(entry.runtime_data, hass.data[WLED_KEY])]) diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py index 2ce58ec9eca..94deed11c08 100644 --- a/homeassistant/components/wmspro/config_flow.py +++ b/homeassistant/components/wmspro/config_flow.py @@ -10,12 +10,11 @@ import aiohttp import voluptuous as vol from wmspro.webcontrol import WebControlPro -from homeassistant.components import dhcp -from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_DHCP, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN, SUGGESTED_HOST @@ -34,7 +33,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle the DHCP discovery step.""" unique_id = format_mac(discovery_info.macaddress) @@ -95,7 +94,7 @@ class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") return self.async_create_entry(title=host, data=user_input) - if self.source == dhcp.DOMAIN: + if self.source == SOURCE_DHCP: discovery_info: DhcpServiceInfo = self.init_data data_values = {CONF_HOST: discovery_info.ip} else: diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index a36b34642b7..715add3023f 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -12,7 +12,7 @@ from wmspro.const import ( from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: WebControlProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WMS based covers from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 9242982bcf9..d181beb1eaa 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -9,7 +9,7 @@ from wmspro.const import WMS_WebControl_pro_API_actionDescription from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness from . import WebControlProConfigEntry @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: WebControlProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WMS based lights from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/wmspro/scene.py b/homeassistant/components/wmspro/scene.py index de18106b7f0..7edd7a2b186 100644 --- a/homeassistant/components/wmspro/scene.py +++ b/homeassistant/components/wmspro/scene.py @@ -9,7 +9,7 @@ from wmspro.scene import Scene as WMS_Scene from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .const import ATTRIBUTION, DOMAIN, MANUFACTURER @@ -18,7 +18,7 @@ from .const import ATTRIBUTION, DOMAIN, MANUFACTURER async def async_setup_entry( hass: HomeAssistant, config_entry: WebControlProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WMS based scenes from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 4bfc0e6dd83..964d192d279 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.15"] + "requirements": ["wolf-comm==0.0.19"] } diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 1f6e6c42464..cf6d712dd0d 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES @@ -26,7 +26,7 @@ from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STA async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Wolf Platform.""" diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 3684208f102..6b878db8159 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -23,10 +23,10 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.event import async_track_point_in_utc_time @@ -113,7 +113,9 @@ def _get_obj_holidays( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Workday sensor.""" add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index bb5e6333b8b..beb828641a4 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.64"] + "requirements": ["holidays==0.67"] } diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 89ea14bbbd0..9b52993919c 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import CONF_TIME_FORMAT, DOMAIN @@ -18,7 +18,7 @@ from .const import CONF_TIME_FORMAT, DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the World clock sensor entry.""" time_zone = await dt_util.async_get_time_zone(entry.options[CONF_TIME_ZONE]) diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 45f39894abb..1a64954bb4a 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 50700b78f35..ed3312fc950 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 83ad7bbf070..32c6a11f25c 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -78,6 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create the coordinator for the WS66i coordinator: Ws66iDataUpdateCoordinator = Ws66iDataUpdateCoordinator( hass, + entry, ws66i, zones, ) diff --git a/homeassistant/components/ws66i/coordinator.py b/homeassistant/components/ws66i/coordinator.py index 013e4d02b15..1b2b43963fc 100644 --- a/homeassistant/components/ws66i/coordinator.py +++ b/homeassistant/components/ws66i/coordinator.py @@ -6,6 +6,7 @@ import logging from pyws66i import WS66i, ZoneStatus +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,9 +18,12 @@ _LOGGER = logging.getLogger(__name__) class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): """DataUpdateCoordinator to gather data for WS66i Zones.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, my_api: WS66i, zones: list[int], ) -> None: @@ -27,6 +31,7 @@ class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="WS66i", update_interval=POLL_INTERVAL, ) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index a2cd7ba471b..fb8ba5ae996 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -10,7 +10,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MAX_VOL @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WS66i 6-zone amplifier platform from a config entry.""" ws66i_data: Ws66iData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 73714b75c95..8ae93c809f2 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 615084bcbf3..5440b2bebeb 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -24,18 +24,20 @@ from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection -from homeassistant.components import assist_pipeline, intent, tts +from homeassistant.components import assist_pipeline, ffmpeg, intent, tts from homeassistant.components.assist_pipeline import PipelineEvent from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, + AssistSatelliteEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService from .devices import SatelliteDevice from .entity import WyomingSatelliteEntity @@ -49,6 +51,8 @@ _RESTART_SECONDS: Final = 3 _PING_TIMEOUT: Final = 5 _PING_SEND_DELAY: Final = 2 _PIPELINE_FINISH_TIMEOUT: Final = 1 +_TTS_SAMPLE_RATE: Final = 22050 +_ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -62,7 +66,7 @@ _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming Assist satellite entity.""" domain_data: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] @@ -83,6 +87,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE def __init__( self, @@ -116,6 +121,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self.device.set_pipeline_listener(self._pipeline_changed) self.device.set_audio_settings_listener(self._audio_settings_changed) + # For announcements + self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None + self._played_event_received: asyncio.Event | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -131,9 +140,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): """Options passed for text-to-speech.""" return { tts.ATTR_PREFERRED_FORMAT: "wav", - tts.ATTR_PREFERRED_SAMPLE_RATE: 16000, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, - tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + tts.ATTR_PREFERRED_SAMPLE_RATE: _TTS_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES: SAMPLE_WIDTH, } async def async_added_to_hass(self) -> None: @@ -244,6 +253,76 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): ) ) + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: + """Announce media on the satellite. + + Should block until the announcement is done playing. + """ + assert self._client is not None + + if self._ffmpeg_manager is None: + self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass) + + if self._played_event_received is None: + self._played_event_received = asyncio.Event() + + self._played_event_received.clear() + await self._client.write_event( + AudioStart( + rate=_TTS_SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + timestamp=0, + ).event() + ) + + timestamp = 0 + try: + # Use ffmpeg to convert to raw PCM audio with the appropriate format + proc = await asyncio.create_subprocess_exec( + self._ffmpeg_manager.binary, + "-i", + announcement.media_id, + "-f", + "s16le", + "-ac", + str(SAMPLE_CHANNELS), + "-ar", + str(_TTS_SAMPLE_RATE), + "-nostats", + "pipe:", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + close_fds=False, # use posix_spawn in CPython < 3.13 + ) + assert proc.stdout is not None + while True: + chunk_bytes = await proc.stdout.read(_ANNOUNCE_CHUNK_BYTES) + if not chunk_bytes: + break + + chunk = AudioChunk( + rate=_TTS_SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + audio=chunk_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + + timestamp += chunk.milliseconds + finally: + await self._client.write_event(AudioStop().event()) + if timestamp > 0: + # Wait the length of the audio or until we receive a played event + audio_seconds = timestamp / 1000 + try: + async with asyncio.timeout(audio_seconds + 0.5): + await self._played_event_received.wait() + except TimeoutError: + # Older satellite clients will wait longer than necessary + _LOGGER.debug("Did not receive played event for announcement") + # ------------------------------------------------------------------------- def start_satellite(self) -> None: @@ -511,6 +590,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): elif Played.is_type(client_event.type): # TTS response has finished playing on satellite self.tts_response_finished() + + if self._played_event_received is not None: + self._played_event_received.set() else: _LOGGER.debug("Unexpected event from satellite: %s", client_event) diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py index 24ee073ec4d..a3652e7f70f 100644 --- a/homeassistant/components/wyoming/binary_sensor.py +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WyomingSatelliteEntity @@ -22,7 +22,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index ddf57cf0ed0..41e7b9cf1e6 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -8,10 +8,10 @@ from urllib.parse import urlparse import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN from .data import WyomingService @@ -117,7 +117,7 @@ class WyomingConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("Zeroconf discovery info: %s", discovery_info) diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 9a17559c1f8..5760d04bfc2 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -12,8 +12,8 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import ulid as ulid_util from .const import DOMAIN from .data import WyomingService @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming conversation.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] @@ -97,7 +97,7 @@ class WyomingConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - conversation_id = user_input.conversation_id or ulid.ulid_now() + conversation_id = user_input.conversation_id or ulid_util.ulid_now() intent_response = intent.IntentResponse(language=user_input.language) try: diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index b837d2a9e76..d75b70dffa8 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -7,7 +7,8 @@ "assist_satellite", "assist_pipeline", "intent", - "conversation" + "conversation", + "ffmpeg" ], "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py index d9a58cc3333..96ec5877545 100644 --- a/homeassistant/components/wyoming/number.py +++ b/homeassistant/components/wyoming/number.py @@ -8,7 +8,7 @@ from homeassistant.components.number import NumberEntityDescription, RestoreNumb from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WyomingSatelliteEntity @@ -24,7 +24,7 @@ _MAX_VOLUME_MULTIPLIER: Final = 10.0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming number entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index bbcaab81710..2af0438e35f 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import SatelliteDevice @@ -36,7 +36,7 @@ _DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming select entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index a28e5fdb527..2851004a854 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -10,7 +10,7 @@ from wyoming.client import AsyncTcpClient from homeassistant.components import stt from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py index 308429331c3..9eb91d5ef39 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WyomingSatelliteEntity @@ -21,7 +21,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 65ce4d942f1..79e431fee98 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -12,7 +12,7 @@ from wyoming.tts import Synthesize, SynthesizeVoice from homeassistant.components import tts from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 64dfd60c068..2a21b7303e5 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -11,7 +11,7 @@ from wyoming.wake import Detect, Detection from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 23343cb0f8d..fbdebe11657 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -81,9 +81,16 @@ class X10Light(LightEntity): @property def brightness(self): - """Return the brightness of the light.""" + """Return the brightness of the light, scaled to base class 0..255. + + This needs to be scaled from 0..x for use with X10 dimmers. + """ return self._brightness + def normalize_x10_brightness(self, brightness: float) -> float: + """Return calculated brightness values.""" + return int((brightness / 255) * 32) + @property def is_on(self): """Return true if light is on.""" @@ -91,11 +98,37 @@ class X10Light(LightEntity): def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - if self._is_cm11a: - x10_command(f"on {self._id}") - else: - x10_command(f"fon {self._id}") + old_brightness = self._brightness + if old_brightness == 0: + # Dim down from max if applicable, also avoids a "dim" command if an "on" is more appropriate + old_brightness = 255 self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness_diff = self.normalize_x10_brightness( + self._brightness + ) - self.normalize_x10_brightness(old_brightness) + command_suffix = "" + # heyu has quite a messy command structure - we'll just deal with it here + if brightness_diff == 0: + if self._is_cm11a: + command_prefix = "on" + else: + command_prefix = "fon" + elif brightness_diff > 0: + if self._is_cm11a: + command_prefix = "bright" + else: + command_prefix = "fbright" + command_suffix = f" {brightness_diff}" + else: + if self._is_cm11a: + if self._state: + command_prefix = "dim" + else: + command_prefix = "dimb" + else: + command_prefix = "fdim" + command_suffix = f" {-brightness_diff}" + x10_command(f"{command_prefix} {self._id}{command_suffix}") self._state = True def turn_off(self, **kwargs: Any) -> None: @@ -104,6 +137,7 @@ class X10Light(LightEntity): x10_command(f"off {self._id}") else: x10_command(f"foff {self._id}") + self._brightness = 0 self._state = False def update(self) -> None: diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..30bc7d59417 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() @@ -46,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: consoles.dict(), ) - coordinator = XboxUpdateCoordinator(hass, client, consoles) + coordinator = XboxUpdateCoordinator(hass, entry, client, consoles) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index af95834425a..5339c4d7a8e 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import XboxUpdateCoordinator @@ -18,7 +18,9 @@ PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 4012820c43c..62c7a35e88b 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -20,6 +20,7 @@ from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleStatus, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -64,9 +65,12 @@ class XboxData: class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): """Store Xbox Console Status.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: XboxLiveClient, consoles: SmartglassConsoleList, ) -> None: @@ -74,6 +78,7 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 7298c7e2da3..6464b2417cc 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -24,7 +24,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .browse_media import build_item_response @@ -56,7 +56,9 @@ XBOX_STATE_MAP: dict[PlaybackState | PowerState, MediaPlayerState | None] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"] diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 1b4ffdf35cc..4e5893ddb13 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -24,7 +24,7 @@ from homeassistant.components.remote import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -32,7 +32,9 @@ from .coordinator import ConsoleData, XboxUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"] diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index f269e0a5bb9..da53557a2d3 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import XboxUpdateCoordinator @@ -20,7 +20,7 @@ SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][ diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 9d4a29d2c78..5968a17f418 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index b7f4aa1942e..6e4d143d84e 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway from homeassistant.components import persistent_notification -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, CONF_HOST, @@ -17,8 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( @@ -217,12 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # No gateways left, stop Xiaomi socket unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index ad91dda2173..47cc823ad7f 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity @@ -32,7 +32,7 @@ ATTR_DENSITY = "Density" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiBinarySensor] = [] diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 6252e6849d0..e0484b80b7e 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -7,11 +7,11 @@ from typing import Any import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery -from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_INTERFACE, @@ -153,7 +153,7 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index e073ef6b683..82d5129ac5e 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice @@ -19,7 +19,7 @@ DATA_KEY_PROTO_V2 = "curtain_status" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index db47015c0cf..59107984ddf 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -57,7 +57,7 @@ class XiaomiDevice(Entity): self._is_gateway = False self._device_id = self._sid - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Start unavailability tracking.""" self._xiaomi_hub.callbacks[self._sid].append(self.push_data) self._async_track_unavailable() @@ -100,7 +100,7 @@ class XiaomiDevice(Entity): return device_info @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._is_available diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index c8057f1df4a..ef1f06695f9 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -13,8 +13,8 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index 5e538f25699..b3f4e9f4caf 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import DOMAIN, GATEWAYS_KEY @@ -24,7 +24,7 @@ UNLOCK_MAINTAIN_TIME = 5 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 49358276a48..59ccee5a1a8 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS from .entity import XiaomiDevice @@ -85,7 +85,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiSensor | XiaomiBatterySensor] = [] diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 75b4ab1ecda..6221b9b9d65 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -26,7 +26,7 @@ } }, "error": { - "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running Home Assistant as interface", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid Gateway key", "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", @@ -59,7 +59,7 @@ }, "ringtone_id": { "name": "Ringtone ID", - "description": "One of the allowed ringtone ids." + "description": "One of the allowed ringtone IDs." }, "ringtone_vol": { "name": "Ringtone volume", diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index f66cf8c7603..7d3abf47bd1 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice @@ -29,7 +29,7 @@ IN_USE = "inuse" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index b853f83b967..8956e207253 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import XiaomiPassiveBluetoothDataProcessor @@ -135,7 +135,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: XiaomiBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index df2de381d39..c293d7832d0 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -306,7 +306,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): return self._async_get_or_create_entry() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py index 7265bcd112c..c5f6e01e575 100644 --- a/homeassistant/components/xiaomi_ble/event.py +++ b/homeassistant/components/xiaomi_ble/event.py @@ -12,7 +12,7 @@ from homeassistant.components.event import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import format_discovered_event_class, format_event_dispatcher_name from .const import ( @@ -183,7 +183,7 @@ class XiaomiEventEntity(EventEntity): async def async_setup_entry( hass: HomeAssistant, entry: XiaomiBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xiaomi event.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index ba8f64383ee..01f15ff09b8 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import XiaomiPassiveBluetoothDataProcessor @@ -208,7 +208,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: XiaomiBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 199d9161353..1ce37c661a2 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -9,7 +9,7 @@ from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_FLOW_TYPE, @@ -242,7 +242,7 @@ DEVICE_MAP: dict[str, dict[str, Callable]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi Air Quality from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 9c06198bc7e..ecab5228f6e 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY, DOMAIN @@ -29,7 +29,7 @@ XIAOMI_STATE_ARMING_VALUE = "oning" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi Gateway Alarm from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index a5ab7e56e6b..213886691f0 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VacuumCoordinatorDataAttributes from .const import ( @@ -171,7 +171,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 9a64941f398..a5d1b4b69c6 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -124,7 +124,7 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the button from a config entry.""" model = config_entry.data[CONF_MODEL] diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b068f4a1e61..c3ebc48d743 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,6 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.components import zeroconf from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -21,6 +20,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_CLOUD_COUNTRY, @@ -145,7 +145,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_cloud() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" name = discovery_info.name diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 1dfc5e53410..518003ceedb 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py index 0343a7526d7..ba1148985ba 100644 --- a/homeassistant/components/xiaomi_miio/entity.py +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -185,7 +185,7 @@ class XiaomiGatewayDevice(CoordinatorEntity, Entity): ) @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" if self.coordinator.data is None: return False diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e1de3f56252..31d5dd9de2c 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -33,8 +33,8 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -205,7 +205,7 @@ FAN_DIRECTIONS_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fan from a config entry.""" entities: list[FanEntity] = [] diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 4701345756a..f19fbec5e78 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -23,7 +23,7 @@ from homeassistant.components.humidifier import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( @@ -71,7 +71,7 @@ AVAILABLE_MODES_OTHER = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Humidifier from a config entry.""" if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE: diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 3f1f8b926b3..81f68306cbc 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -42,9 +42,9 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util from .const import ( @@ -132,7 +132,7 @@ SERVICE_TO_METHOD = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi light from a config entry.""" entities: list[LightEntity] = [] diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a3c501aad3f..f30d4728275 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -289,7 +289,7 @@ FAVORITE_LEVEL_VALUES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Selectors from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index eb0d6bca205..94a93fc1fae 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -32,7 +32,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_FLOW_TYPE, @@ -205,7 +205,7 @@ SELECTOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Selectors from a config entry.""" if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE: @@ -260,10 +260,10 @@ class XiaomiGenericSelector(XiaomiSelector): if description.options_map: self._options_map = {} - for key, val in enum_class._member_map_.items(): # noqa: SLF001 + for key, val in enum_class._member_map_.items(): self._options_map[description.options_map[key]] = val else: - self._options_map = enum_class._member_map_ # noqa: SLF001 + self._options_map = enum_class._member_map_ self._reverse_map = {val: key for key, val in self._options_map.items()} self._enum_class = enum_class diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index aafcba97487..6f623c46af8 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes @@ -755,7 +755,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi sensor from a config entry.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bafc1ec543b..bd3b3499689 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the xiaomi miio entity." + "description": "Name of the Xiaomi Miio entity." } } }, @@ -365,7 +365,7 @@ }, "light_set_delayed_turn_off": { "name": "Light set delayed turn off", - "description": "Delayed turn off.", + "description": "Sets the delayed turning off of a light.", "fields": { "entity_id": { "name": "Entity ID", @@ -373,7 +373,7 @@ }, "time_period": { "name": "Time period", - "description": "Time period for the delayed turn off." + "description": "Time period for the delayed turning off." } } }, @@ -398,8 +398,8 @@ } }, "light_night_light_mode_on": { - "name": "Night light mode on", - "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode on", + "description": "Turns on the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -408,8 +408,8 @@ } }, "light_night_light_mode_off": { - "name": "Night light mode off", - "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode off", + "description": "Turns off the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -419,7 +419,7 @@ }, "light_eyecare_mode_on": { "name": "Light eyecare mode on", - "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]", + "description": "Turns on the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -429,7 +429,7 @@ }, "light_eyecare_mode_off": { "name": "Light eyecare mode off", - "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]", + "description": "Turns off the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -439,7 +439,7 @@ }, "remote_learn_command": { "name": "Remote learn command", - "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "description": "Learns an IR command. Select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", "fields": { "slot": { "name": "Slot", @@ -447,21 +447,21 @@ }, "timeout": { "name": "Timeout", - "description": "Define the timeout, before which the command must be learned." + "description": "Define the timeout before which the command must be learned." } } }, "remote_set_led_on": { "name": "Remote set LED on", - "description": "Turns on blue LED." + "description": "Turns on the remote’s blue LED." }, "remote_set_led_off": { "name": "Remote set LED off", - "description": "Turns off blue LED." + "description": "Turns off the remote’s blue LED." }, "switch_set_wifi_led_on": { "name": "Switch set Wi-Fi LED on", - "description": "Turns the wifi led on.", + "description": "Turns on the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", @@ -471,7 +471,7 @@ }, "switch_set_wifi_led_off": { "name": "Switch set Wi-Fi LED off", - "description": "Turn the Wi-Fi led off.", + "description": "Turns off the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", @@ -509,7 +509,7 @@ }, "vacuum_remote_control_start": { "name": "Vacuum remote control start", - "description": "Starts remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`." + "description": "Starts remote control of the vacuum cleaner. You can then move it with the 'Vacuum remote control move' action, when done use 'Vacuum remote control stop'." }, "vacuum_remote_control_stop": { "name": "Vacuum remote control stop", @@ -517,7 +517,7 @@ }, "vacuum_remote_control_move": { "name": "Vacuum remote control move", - "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`.", + "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with the 'Vacuum remote control start' action.", "fields": { "velocity": { "name": "Velocity", @@ -567,7 +567,7 @@ }, "vacuum_goto": { "name": "Vacuum go to", - "description": "Go to the specified coordinates.", + "description": "Sends the robot to the specified coordinates.", "fields": { "x_coord": { "name": "X coordinate", diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 02f4d4e94e5..e4b94aebc20 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -29,8 +29,8 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_FLOW_TYPE, @@ -341,7 +341,7 @@ SWITCH_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch from a config entry.""" model = config_entry.data[CONF_MODEL] diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 532eb9581cd..1cbc79b89f3 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import as_utc @@ -79,7 +79,7 @@ STATE_CODE_TO_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi vacuum cleaner robot from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py index 675c802f79c..19cb4faf2b9 100644 --- a/homeassistant/components/xiaomi_tv/media_player.py +++ b/homeassistant/components/xiaomi_tv/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 3fb5dd166a1..968f925d1e8 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -35,8 +35,7 @@ from homeassistant.const import ( CONF_SENDER, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py index 6f7197817d7..15fb9d021c6 100644 --- a/homeassistant/components/xs1/__init__.py +++ b/homeassistant/components/xs1/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py index 7239a6fd446..c1ec43ec33c 100644 --- a/homeassistant/components/xs1/entity.py +++ b/homeassistant/components/xs1/entity.py @@ -17,7 +17,7 @@ class XS1DeviceEntity(Entity): """Initialize the XS1 device.""" self.device = device - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest device state.""" async with UPDATE_LOCK: await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/yale/binary_sensor.py b/homeassistant/components/yale/binary_sensor.py index dbb00ad7d42..bb9acb16644 100644 --- a/homeassistant/components/yale/binary_sensor.py +++ b/homeassistant/components/yale/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import YaleConfigEntry, YaleData @@ -92,7 +92,7 @@ SENSOR_TYPES_DOORBELL: tuple[YaleDoorbellBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale binary sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/button.py b/homeassistant/components/yale/button.py index b04ad638f0c..005d477e4ca 100644 --- a/homeassistant/components/yale/button.py +++ b/homeassistant/components/yale/button.py @@ -2,7 +2,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .entity import YaleEntity @@ -11,7 +11,7 @@ from .entity import YaleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yale lock wake buttons.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/camera.py b/homeassistant/components/yale/camera.py index 217e8f5f6fd..acabba23b59 100644 --- a/homeassistant/components/yale/camera.py +++ b/homeassistant/components/yale/camera.py @@ -12,7 +12,7 @@ from yalexs.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry, YaleData from .const import DEFAULT_NAME, DEFAULT_TIMEOUT @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yale cameras.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/event.py b/homeassistant/components/yale/event.py index 935ba7376f8..0ea7694be6d 100644 --- a/homeassistant/components/yale/event.py +++ b/homeassistant/components/yale/event.py @@ -16,7 +16,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry, YaleData from .entity import YaleDescriptionEntity @@ -59,7 +59,7 @@ TYPES_DOORBELL: tuple[YaleEventEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the yale event platform.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index b911c92ba0f..079c1dcd3dd 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -14,9 +14,9 @@ from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import YaleConfigEntry, YaleData from .entity import YaleEntity @@ -29,7 +29,7 @@ LOCK_JAMMED_ERR = 531 async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yale locks.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index f1cde31d066..5c8e98b1e6e 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] } diff --git a/homeassistant/components/yale/sensor.py b/homeassistant/components/yale/sensor.py index bb3d4317277..91ecbea704d 100644 --- a/homeassistant/components/yale/sensor.py +++ b/homeassistant/components/yale/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import ( @@ -82,7 +82,7 @@ SENSOR_TYPE_KEYPAD_BATTERY = YaleSensorEntityDescription[KeypadDetail]( async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 8244d96064a..b443ba016d6 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -17,7 +17,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import DOMAIN, STATE_MAP, YALE_ALL_ERRORS @@ -26,7 +26,9 @@ from .entity import YaleAlarmEntity async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the alarm entry.""" diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 17b6035321a..20fe3648eed 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator @@ -44,7 +44,9 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale binary sensor entry.""" @@ -86,7 +88,7 @@ class YaleDoorBatterySensor(YaleEntity, BinarySensorEntity): ) -> None: """Initiate Yale door battery Sensor.""" super().__init__(coordinator, data) - self._attr_unique_id = f"{data["address"]}-battery" + self._attr_unique_id = f"{data['address']}-battery" @property def is_on(self) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 0e53c814fd4..0875ab4514d 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import DOMAIN, YALE_ALL_ERRORS @@ -25,7 +25,7 @@ BUTTON_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the button from a config entry.""" diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 3ceee367284..1aaad2aa63a 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_AREA_ID, diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 7ece2a3448b..db63567fa92 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -84,7 +84,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): contact["address"]: contact["_state"] for contact in door_windows } _sensor_battery_map = { - f"{contact["address"]}-battery": contact["_battery"] + f"{contact['address']}-battery": contact["_battery"] for contact in door_windows } _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 7a93baf0827..f4fae531b67 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity, LockState from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import ( @@ -30,7 +30,9 @@ LOCK_STATE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale lock entry.""" diff --git a/homeassistant/components/yale_smart_alarm/select.py b/homeassistant/components/yale_smart_alarm/select.py index 55b56dd8e54..0b443e762e6 100644 --- a/homeassistant/components/yale_smart_alarm/select.py +++ b/homeassistant/components/yale_smart_alarm/select.py @@ -6,7 +6,7 @@ from yalesmartalarmclient import YaleLock, YaleLockVolume from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator @@ -16,7 +16,9 @@ VOLUME_OPTIONS = {value.name.lower(): str(value.value) for value in YaleLockVolu async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale select entry.""" diff --git a/homeassistant/components/yale_smart_alarm/sensor.py b/homeassistant/components/yale_smart_alarm/sensor.py index 50343f2e41f..14301d0c6b5 100644 --- a/homeassistant/components/yale_smart_alarm/sensor.py +++ b/homeassistant/components/yale_smart_alarm/sensor.py @@ -7,7 +7,7 @@ from typing import cast from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import YaleConfigEntry @@ -15,7 +15,9 @@ from .entity import YaleEntity async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale sensor entry.""" diff --git a/homeassistant/components/yale_smart_alarm/switch.py b/homeassistant/components/yale_smart_alarm/switch.py index e8c0817c2de..e4523a66802 100644 --- a/homeassistant/components/yale_smart_alarm/switch.py +++ b/homeassistant/components/yale_smart_alarm/switch.py @@ -8,7 +8,7 @@ from yalesmartalarmclient import YaleLock from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator @@ -16,7 +16,9 @@ from .entity import YaleLockEntity async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale switch entry.""" diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py index 7cd142bb9ba..dc924486df2 100644 --- a/homeassistant/components/yalexs_ble/binary_sensor.py +++ b/homeassistant/components/yalexs_ble/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity @@ -18,7 +18,7 @@ from .entity import YALEXSBLEEntity async def async_setup_entry( hass: HomeAssistant, entry: YALEXSBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YALE XS binary sensors.""" data = entry.runtime_data diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 6de74759686..0e1eabdf6b2 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -267,7 +267,7 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN): if discovery := self._discovery_info: self._discovered_devices[discovery.address] = discovery else: - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) current_unique_names = { entry.data.get(CONF_LOCAL_NAME) for entry in self._async_current_entries() diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 6eb32e3f78a..78b92ab9eb1 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -8,7 +8,7 @@ from yalexs_ble import ConnectionInfo, LockInfo, LockState, LockStatus from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity @@ -17,7 +17,7 @@ from .entity import YALEXSBLEEntity async def async_setup_entry( hass: HomeAssistant, entry: YALEXSBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks.""" async_add_entities([YaleXSBLELock(entry.runtime_data)]) diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 15b11719fdb..c44f0fdd1e9 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.5.6"] + "requirements": ["yalexs-ble==2.5.7"] } diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 90f61219e0b..bc9312effe3 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity @@ -75,7 +75,7 @@ SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: YALEXSBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YALE XS Bluetooth sensors.""" data = entry.runtime_data diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index a2ce98dde56..3e890c8b943 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), entry.data[CONF_UPNP_DESC], ) - coordinator = MusicCastDataUpdateCoordinator(hass, client=client) + coordinator = MusicCastDataUpdateCoordinator(hass, entry, client=client) await coordinator.async_config_entry_first_refresh() coordinator.musiccast.build_capabilities() diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index d6ad54c4a3d..c43e547a71e 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -10,10 +10,14 @@ from aiohttp import ClientConnectorError from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol -from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from . import get_upnp_desc from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN @@ -81,7 +85,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( @@ -89,7 +93,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="yxc_control_url_missing") - self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.serial_number = discovery_info.upnp[ATTR_UPNP_SERIAL] self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment @@ -105,9 +109,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update( { "title_placeholders": { - "name": discovery_info.upnp.get( - ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host - ) + "name": discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME, self.host) } } ) diff --git a/homeassistant/components/yamaha_musiccast/coordinator.py b/homeassistant/components/yamaha_musiccast/coordinator.py index d5e0c67310a..13afbe3aa5e 100644 --- a/homeassistant/components/yamaha_musiccast/coordinator.py +++ b/homeassistant/components/yamaha_musiccast/coordinator.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from aiomusiccast import MusicCastConnectionException from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,11 +26,21 @@ SCAN_INTERVAL = timedelta(seconds=60) class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): """Class to manage fetching data from the API.""" - def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: MusicCastDevice + ) -> None: """Initialize.""" self.musiccast = client - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.entities: list[MusicCastDeviceEntity] = [] async def _async_update_data(self) -> MusicCastData: diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py index 4f1add825e4..8023b13c10a 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -78,13 +78,13 @@ class MusicCastDeviceEntity(MusicCastEntity): return device_info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" await super().async_added_to_hass() # All entities should register callbacks to update HA when their state changes self.coordinator.musiccast.register_callback(self.async_write_ha_state) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Entity being removed from hass.""" await super().async_will_remove_from_hass() self.coordinator.musiccast.remove_callback(self.async_write_ha_state) diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 4384cc34836..7bf139e9c3b 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -24,8 +24,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import uuid +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import uuid as uuid_util from .const import ( ATTR_MAIN_SYNC, @@ -55,7 +55,7 @@ MUSIC_PLAYER_BASE_SUPPORT = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast sensor based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -735,7 +735,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): group = ( self.coordinator.data.group_id if self.is_server - else uuid.random_uuid_hex().upper() + else uuid_util.random_uuid_hex().upper() ) ip_addresses = set() diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index 02dd6720d91..0de14ef142d 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -7,7 +7,7 @@ from aiomusiccast.capabilities import NumberSetter from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast number entities based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index 3a4649b9ae5..133cb4c4d7b 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -7,7 +7,7 @@ from aiomusiccast.capabilities import OptionSetter from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TRANSLATION_KEY_MAPPING from .coordinator import MusicCastDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast select entities based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yamaha_musiccast/switch.py b/homeassistant/components/yamaha_musiccast/switch.py index 49d031a02b5..148f09930f3 100644 --- a/homeassistant/components/yamaha_musiccast/switch.py +++ b/homeassistant/components/yamaha_musiccast/switch.py @@ -7,7 +7,7 @@ from aiomusiccast.capabilities import BinarySetter from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast sensor based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 95c4785a341..f87d29fffed 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -15,11 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 850afd05150..c7621eb639a 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index b0c8a882474..da016ca4ec4 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -28,6 +28,8 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): """Coordinator for Yardian API calls.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -38,6 +40,7 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry.title, update_interval=SCAN_INTERVAL, always_update=False, diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index 910bacc1c2e..6531a48dc82 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry 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.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,7 +26,7 @@ SERVICE_SCHEMA_START_IRRIGATION: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Yardian irrigation switches.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 9b71bbc3b16..0b3ceaf2aee 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, VolDictType from .const import ( diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 9993272d510..69427c65fd5 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN from .entity import YeelightEntity @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 7a3a0a2f100..15975ba22bd 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -11,7 +11,7 @@ import yeelight from yeelight.aio import AsyncBulb from yeelight.main import get_known_models -from homeassistant.components import dhcp, onboarding, ssdp, zeroconf +from homeassistant.components import onboarding from homeassistant.config_entries import ( ConfigEntry, ConfigEntryState, @@ -22,7 +22,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from .const import ( @@ -69,21 +72,21 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices: dict[str, Any] = {} async def async_step_homekit( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from homekit.""" self._discovered_ip = discovery_info.host return await self._async_handle_discovery() async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle discovery from dhcp.""" self._discovered_ip = discovery_info.ip return await self._async_handle_discovery() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle discovery from zeroconf.""" self._discovered_ip = discovery_info.host @@ -91,7 +94,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_handle_discovery_with_unique_id() async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo + self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: """Handle discovery from ssdp.""" self._discovered_ip = urlparse(discovery_info.ssdp_headers["location"]).hostname diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8cc3f2600e5..a2f705298c9 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -32,13 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import YEELIGHT_FLOW_TRANSITION_SCHEMA from .const import ( @@ -279,7 +278,7 @@ def _async_cmd[_YeelightBaseLightT: YeelightBaseLight, **_P, _R]( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS]) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index eba970dc2db..cf7bc9c9035 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.42.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 7e908396ff3..75156ab019b 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -16,10 +16,11 @@ from async_upnp_client.search import SsdpSearchListener from async_upnp_client.utils import CaseInsensitiveDict from homeassistant import config_entries -from homeassistant.components import network, ssdp +from homeassistant.components import network from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.util.async_ import create_eager_task from .const import ( @@ -171,7 +172,7 @@ class YeelightScanner: self._hass, DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="", ssdp_st=SSDP_ST, ssdp_headers=response, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 72e400b7cf3..d53c28cb64a 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -161,11 +161,11 @@ }, "set_music_mode": { "name": "Set music mode", - "description": "Enables or disables music_mode.", + "description": "Enables or disables music mode.", "fields": { "music_mode": { "name": "Music mode", - "description": "Use true or false to enable / disable music_mode." + "description": "Whether to enable or disable music mode." } } } diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py index 0d8247fc865..4cacd1def22 100644 --- a/homeassistant/components/yeelightsunflower/light.py +++ b/homeassistant/components/yeelightsunflower/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 004c5a70cc1..7ba7433f53f 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from yolink.const import ATTR_DEVICE_SMART_REMOTER +from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome @@ -75,7 +75,8 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinator.async_set_updated_data(msg_data) # handling events if ( - device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER + device_coordinator.device.device_type + in [ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH] and msg_data.get("event") is not None ): device_registry = dr.async_get(self._hass) @@ -152,7 +153,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: paried_device_id := device_pairing_mapping.get(device.device_id) ) is not None: paried_device = yolink_home.get_device(paried_device_id) - device_coordinator = YoLinkCoordinator(hass, device, paried_device) + device_coordinator = YoLinkCoordinator(hass, entry, device, paried_device) try: await device_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index fa4c2202b03..30c04d3a424 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -101,7 +101,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index ff3bbf0d93b..65253094fa9 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -47,7 +47,7 @@ YOLINK_ACTION_2_HA = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Thermostat from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index eb6169eccad..8879ef15125 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -33,3 +33,7 @@ DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC" DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC" DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC" DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC" +DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" +DEV_MODEL_SWITCH_YS5708_EC = "YS5708-EC" +DEV_MODEL_SWITCH_YS5709_UC = "YS5709-UC" +DEV_MODEL_SWITCH_YS5709_EC = "YS5709-EC" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index b7db36541b1..d18a37bd276 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -9,6 +9,7 @@ import logging from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,9 +22,12 @@ _LOGGER = logging.getLogger(__name__) class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, device: YoLinkDevice, paired_device: YoLinkDevice | None = None, ) -> None: @@ -34,7 +38,11 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): data at first update """ super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=30), ) self.device = device self.paired_device = paired_device diff --git a/homeassistant/components/yolink/cover.py b/homeassistant/components/yolink/cover.py index b2454bd0d4a..b1cfc3681cc 100644 --- a/homeassistant/components/yolink/cover.py +++ b/homeassistant/components/yolink/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -24,7 +24,7 @@ from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink garage door from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py index 6e247bf858e..6f5ed8b24fa 100644 --- a/homeassistant/components/yolink/device_trigger.py +++ b/homeassistant/components/yolink/device_trigger.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any import voluptuous as vol -from yolink.const import ATTR_DEVICE_SMART_REMOTER +from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger @@ -21,6 +21,10 @@ from .const import ( DEV_MODEL_FLEX_FOB_YS3604_UC, DEV_MODEL_FLEX_FOB_YS3614_EC, DEV_MODEL_FLEX_FOB_YS3614_UC, + DEV_MODEL_SWITCH_YS5708_EC, + DEV_MODEL_SWITCH_YS5708_UC, + DEV_MODEL_SWITCH_YS5709_EC, + DEV_MODEL_SWITCH_YS5709_UC, ) CONF_BUTTON_1 = "button_1" @@ -30,7 +34,7 @@ CONF_BUTTON_4 = "button_4" CONF_SHORT_PRESS = "short_press" CONF_LONG_PRESS = "long_press" -FLEX_FOB_4_BUTTONS = { +FLEX_BUTTONS_4 = { f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", @@ -41,7 +45,7 @@ FLEX_FOB_4_BUTTONS = { f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", } -FLEX_FOB_2_BUTTONS = { +FLEX_BUTTONS_2 = { f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", @@ -49,16 +53,19 @@ FLEX_FOB_2_BUTTONS = { } TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( - {vol.Required(CONF_TYPE): vol.In(FLEX_FOB_4_BUTTONS)} + {vol.Required(CONF_TYPE): vol.In(FLEX_BUTTONS_4)} ) - -# YoLink Remotes YS3604/YS3614 -FLEX_FOB_TRIGGER_TYPES: dict[str, set[str]] = { - DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_FOB_4_BUTTONS, - DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_FOB_4_BUTTONS, - DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_FOB_2_BUTTONS, - DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_FOB_2_BUTTONS, +# YoLink Remotes YS3604/YS3614, Switch YS5708/YS5709 +TRIGGER_MAPPINGS: dict[str, set[str]] = { + DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_BUTTONS_4, + DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_BUTTONS_4, + DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_BUTTONS_2, + DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5708_EC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5708_UC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5709_EC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5709_UC: FLEX_BUTTONS_2, } @@ -68,9 +75,12 @@ async def async_get_triggers( """List device triggers for YoLink devices.""" device_registry = dr.async_get(hass) registry_device = device_registry.async_get(device_id) - if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + if not registry_device or registry_device.model not in [ + ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SWITCH, + ]: return [] - if registry_device.model_id not in list(FLEX_FOB_TRIGGER_TYPES.keys()): + if registry_device.model_id not in list(TRIGGER_MAPPINGS.keys()): return [] return [ { @@ -79,7 +89,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_TYPE: trigger, } - for trigger in FLEX_FOB_TRIGGER_TYPES[registry_device.model_id] + for trigger in TRIGGER_MAPPINGS[registry_device.model_id] ] diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py index e07d17f7d74..54470673fa5 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -10,7 +10,7 @@ from yolink.const import ATTR_DEVICE_DIMMER from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -20,7 +20,7 @@ from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Dimmer from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index d675fd8cf06..5e244dd08f2 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -10,7 +10,7 @@ from yolink.const import ATTR_DEVICE_LOCK, ATTR_DEVICE_LOCK_V2 from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -20,7 +20,7 @@ from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink lock from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators @@ -51,15 +51,16 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" state_value = state.get("state") - if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: - self._attr_is_locked = ( - state_value["lock"] == "locked" if state_value is not None else None - ) - else: - self._attr_is_locked = ( - state_value == "locked" if state_value is not None else None - ) - self.async_write_ha_state() + if state_value is not None: + if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: + self._attr_is_locked = ( + state_value["lock"] == "locked" if state_value is not None else None + ) + else: + self._attr_is_locked = ( + state_value == "locked" if state_value is not None else None + ) + self.async_write_ha_state() async def call_lock_state_change(self, state: str) -> None: """Call setState api to change lock state.""" @@ -69,7 +70,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): ) else: await self.call_device(ClientRequest("setState", {"state": state})) - self._attr_is_locked = state == "lock" + self._attr_is_locked = state in ["locked", "lock"] self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 78b553d7978..52ae8281f59 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.7"] + "requirements": ["yolink-api==0.4.8"] } diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py index 7b7b582382b..c643a20d0ea 100644 --- a/homeassistant/components/yolink/number.py +++ b/homeassistant/components/yolink/number.py @@ -17,7 +17,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -66,7 +66,7 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device number type config option entity from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 8f263cdae07..511b7718e26 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -47,7 +47,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import percentage from .const import ( @@ -280,7 +280,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index a011d493dc9..f17408a7005 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -19,6 +19,11 @@ from .const import ( SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" +_SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = ( + (ATTR_VOLUME, lambda x: x), + (ATTR_TONE, lambda x: x.capitalize()), +) + def async_register_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" @@ -34,7 +39,7 @@ def async_register_services(hass: HomeAssistant) -> None: continue if entry.domain == DOMAIN: break - if entry is None or entry.state == ConfigEntryState.NOT_LOADED: + if entry is None or entry.state != ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_config_entry", @@ -46,16 +51,16 @@ def async_register_services(hass: HomeAssistant) -> None: identifier[1] ) ) is not None: - tone_param = service_data[ATTR_TONE].capitalize() - play_request = ClientRequest( - "playAudio", - { - ATTR_TONE: tone_param, - ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], - ATTR_VOLUME: service_data[ATTR_VOLUME], - ATTR_REPEAT: service_data[ATTR_REPEAT], - }, - ) + params = { + ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], + ATTR_REPEAT: service_data[ATTR_REPEAT], + } + + for attr, transform in _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS: + if attr in service_data: + params[attr] = transform(service_data[attr]) + + play_request = ClientRequest("playAudio", params) await device_coordinator.device.call_device(play_request) hass.services.async_register( @@ -64,9 +69,9 @@ def async_register_services(hass: HomeAssistant) -> None: schema=vol.Schema( { vol.Required(ATTR_TARGET_DEVICE): cv.string, - vol.Required(ATTR_TONE): cv.string, + vol.Optional(ATTR_TONE): cv.string, vol.Required(ATTR_TEXT_MESSAGE): cv.string, - vol.Required(ATTR_VOLUME): vol.All( + vol.Optional(ATTR_VOLUME): vol.All( vol.Coerce(int), vol.Range(min=0, max=15) ), vol.Optional(ATTR_REPEAT, default=0): vol.All( diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml index 5f7a3ec3122..7375962070e 100644 --- a/homeassistant/components/yolink/services.yaml +++ b/homeassistant/components/yolink/services.yaml @@ -14,7 +14,6 @@ play_on_speaker_hub: selector: text: tone: - required: true default: "tip" selector: select: @@ -25,7 +24,6 @@ play_on_speaker_hub: - "tip" translation_key: speaker_tone volume: - required: true default: 8 selector: number: @@ -33,7 +31,6 @@ play_on_speaker_hub: max: 15 step: 1 repeat: - required: true default: 0 selector: number: diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 9e02f50bb70..d13e2dc6573 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -17,7 +17,7 @@ from homeassistant.components.siren import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -46,7 +46,7 @@ DEVICE_TYPE = [ATTR_DEVICE_SIREN] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink siren from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 2f9a9454502..8ec7612fd73 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -6,7 +6,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The yolink integration needs to re-authenticate your account" + "description": "The YoLink integration needs to re-authenticate your account" } }, "abort": { @@ -99,11 +99,11 @@ "services": { "play_on_speaker_hub": { "name": "Play on SpeakerHub", - "description": "Convert text to audio play on YoLink SpeakerHub", + "description": "Converts text to speech for playback on a YoLink SpeakerHub", "fields": { "target_device": { - "name": "SpeakerHub Device", - "description": "SpeakerHub Device" + "name": "SpeakerHub device", + "description": "SpeakerHub device for audio playback." }, "message": { "name": "Text message", @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Speaker volume during playback." + "description": "Overrides the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index c999f04d90d..2af7a3c9ddc 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -23,7 +23,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_MULTI_OUTLET_YS6801, DOMAIN from .coordinator import YoLinkCoordinator @@ -116,7 +116,7 @@ DEVICE_TYPE = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink switch from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators @@ -162,11 +162,12 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): @callback def update_entity_state(self, state: dict[str, str | list[str]]) -> None: """Update HA Entity State.""" - self._attr_is_on = self._get_state( - state.get("state"), - self.entity_description.plug_index_fn(self.coordinator.device), - ) - self.async_write_ha_state() + if (state_value := state.get("state")) is not None: + self._attr_is_on = self._get_state( + state_value, + self.entity_description.plug_index_fn(self.coordinator.device), + ) + self.async_write_ha_state() async def call_state_change(self, state: str) -> None: """Call setState api to change switch state.""" diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index d8c199697c3..26ce72a53d1 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -17,7 +17,7 @@ from homeassistant.components.valve import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_WATER_METER_YS5007, DOMAIN from .coordinator import YoLinkCoordinator @@ -50,7 +50,7 @@ DEVICE_TYPE = [ATTR_DEVICE_WATER_METER_CONTROLLER] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink valve from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index d475034cc9d..af14d597b79 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -1,6 +1,5 @@ """The youless integration.""" -from datetime import timedelta import logging from urllib.error import URLError @@ -10,9 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import YouLessCoordinator PLATFORMS = [Platform.SENSOR] @@ -28,24 +27,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except URLError as exception: raise ConfigEntryNotReady from exception - async def async_update_data() -> YoulessAPI: - """Fetch data from the API.""" - await hass.async_add_executor_job(api.update) - return api - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="youless_gateway", - update_method=async_update_data, - update_interval=timedelta(seconds=10), - ) - - await coordinator.async_config_entry_first_refresh() + youless_coordinator = YouLessCoordinator(hass, entry, api) + await youless_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data[DOMAIN][entry.entry_id] = youless_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py new file mode 100644 index 00000000000..81e4b3a4c76 --- /dev/null +++ b/homeassistant/components/youless/coordinator.py @@ -0,0 +1,34 @@ +"""The coordinator for the Youless integration.""" + +from datetime import timedelta +import logging + +from youless_api import YoulessAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class YouLessCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching YouLess data.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: YoulessAPI + ) -> None: + """Initialize global YouLess data provider.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="youless_gateway", + update_interval=timedelta(seconds=10), + ) + self.device = device + + async def _async_update_data(self) -> None: + await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py new file mode 100644 index 00000000000..4500fe71a96 --- /dev/null +++ b/homeassistant/components/youless/entity.py @@ -0,0 +1,25 @@ +"""The entity for the Youless integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import YouLessCoordinator + + +class YouLessEntity(CoordinatorEntity[YouLessCoordinator]): + """Base entity for YouLess.""" + + def __init__( + self, coordinator: YouLessCoordinator, device_group: str, device_name: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_group)}, + manufacturer="YouLess", + model=self.device.model, + translation_key=device_name, + sw_version=self.device.firmware_version, + ) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 1ccc8cda0ff..9a51e0fe0d1 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==2.1.2"] + "requirements": ["youless-api==2.2.0"] } diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index ed0fc703cc4..be17bed4352 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -2,12 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from youless_api import YoulessAPI -from youless_api.youless_sensor import YoulessSensor from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -20,346 +23,325 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from . import DOMAIN +from .coordinator import YouLessCoordinator +from .entity import YouLessEntity + + +@dataclass(frozen=True, kw_only=True) +class YouLessSensorEntityDescription(SensorEntityDescription): + """Describes a YouLess sensor entity.""" + + device_group: str + value_func: Callable[[YoulessAPI], float | None | str] + + +SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( + YouLessSensorEntityDescription( + key="water", + device_group="water", + translation_key="total_water", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_func=( + lambda device: device.water_meter.value if device.water_meter else None + ), + ), + YouLessSensorEntityDescription( + key="gas", + device_group="gas", + translation_key="total_gas_m3", + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_func=lambda device: device.gas_meter.value if device.gas_meter else None, + ), + YouLessSensorEntityDescription( + key="usage", + device_group="power", + translation_key="active_power_w", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.current_power_usage.value + if device.current_power_usage + else None + ), + ), + YouLessSensorEntityDescription( + key="power_low", + device_group="power", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "1"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.low.value if device.power_meter else None + ), + ), + YouLessSensorEntityDescription( + key="power_high", + device_group="power", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "2"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.high.value if device.power_meter else None + ), + ), + YouLessSensorEntityDescription( + key="power_total", + device_group="power", + translation_key="total_energy_import_kwh", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.power_meter.total.value + if device.power_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="phase_1_power", + device_group="power", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "1"}, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase1.power.value if device.phase1 else None, + ), + YouLessSensorEntityDescription( + key="phase_1_voltage", + device_group="power", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "1"}, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase1.voltage.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_1_current", + device_group="power", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "1"}, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase1.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_2_power", + device_group="power", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "2"}, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase2.power.value if device.phase2 else None, + ), + YouLessSensorEntityDescription( + key="phase_2_voltage", + device_group="power", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "2"}, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase2.voltage.value if device.phase2 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_2_current", + device_group="power", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "2"}, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase2.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_3_power", + device_group="power", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "3"}, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.phase3.power.value if device.phase3 else None, + ), + YouLessSensorEntityDescription( + key="phase_3_voltage", + device_group="power", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "3"}, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=( + lambda device: device.phase3.voltage.value if device.phase3 else None + ), + ), + YouLessSensorEntityDescription( + key="phase_3_current", + device_group="power", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "3"}, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=( + lambda device: device.phase3.current.value if device.phase1 else None + ), + ), + YouLessSensorEntityDescription( + key="tariff", + device_group="power", + translation_key="active_tariff", + device_class=SensorDeviceClass.ENUM, + options=["1", "2"], + value_func=( + lambda device: str(device.current_tariff) if device.current_tariff else None + ), + ), + YouLessSensorEntityDescription( + key="average_peak", + device_group="power", + translation_key="average_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.average_power.value if device.average_power else None + ), + ), + YouLessSensorEntityDescription( + key="month_peak", + device_group="power", + translation_key="month_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.peak_power.value if device.peak_power else None + ), + ), + YouLessSensorEntityDescription( + key="delivery_low", + device_group="delivery", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "1"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.delivery_meter.low.value + if device.delivery_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="delivery_high", + device_group="delivery", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "2"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.delivery_meter.high.value + if device.delivery_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="extra_total", + device_group="extra", + translation_key="total_s0_kwh", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_func=( + lambda device: device.extra_meter.total.value + if device.extra_meter + else None + ), + ), + YouLessSensorEntityDescription( + key="extra_usage", + device_group="extra", + translation_key="active_s0_w", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.extra_meter.usage.value + if device.extra_meter + else None + ), + ), +) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the integration.""" - coordinator: DataUpdateCoordinator[YoulessAPI] = hass.data[DOMAIN][entry.entry_id] + coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id] device = entry.data[CONF_DEVICE] if (device := entry.data[CONF_DEVICE]) is None: device = entry.entry_id async_add_entities( [ - WaterSensor(coordinator, device), - GasSensor(coordinator, device), - EnergyMeterSensor( - coordinator, device, "low", SensorStateClass.TOTAL_INCREASING - ), - EnergyMeterSensor( - coordinator, device, "high", SensorStateClass.TOTAL_INCREASING - ), - EnergyMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), - CurrentPowerSensor(coordinator, device), - DeliveryMeterSensor(coordinator, device, "low"), - DeliveryMeterSensor(coordinator, device, "high"), - ExtraMeterSensor(coordinator, device, "total"), - ExtraMeterPowerSensor(coordinator, device, "usage"), - PhasePowerSensor(coordinator, device, 1), - PhaseVoltageSensor(coordinator, device, 1), - PhaseCurrentSensor(coordinator, device, 1), - PhasePowerSensor(coordinator, device, 2), - PhaseVoltageSensor(coordinator, device, 2), - PhaseCurrentSensor(coordinator, device, 2), - PhasePowerSensor(coordinator, device, 3), - PhaseVoltageSensor(coordinator, device, 3), - PhaseCurrentSensor(coordinator, device, 3), + YouLessSensor(coordinator, description, device) + for description in SENSOR_TYPES ] ) -class YoulessBaseSensor( - CoordinatorEntity[DataUpdateCoordinator[YoulessAPI]], SensorEntity -): - """The base sensor for Youless.""" +class YouLessSensor(YouLessEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: YouLessSensorEntityDescription + _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[YoulessAPI], + coordinator: YouLessCoordinator, + description: YouLessSensorEntityDescription, device: str, - device_group: str, - friendly_name: str, - sensor_id: str, ) -> None: - """Create the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{device}_{device_group}")}, - manufacturer="YouLess", - model=self.coordinator.data.model, - name=friendly_name, + """Initialize the sensor.""" + super().__init__( + coordinator, + f"{device}_{description.device_group}", + description.device_group, ) - - @property - def get_sensor(self) -> YoulessSensor | None: - """Property to get the underlying sensor object.""" - return None + self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}" + self.entity_description = description @property def native_value(self) -> StateType: - """Determine the state value, only if a sensor is initialized.""" - if self.get_sensor is None: - return None - - return self.get_sensor.value - - @property - def available(self) -> bool: - """Return a flag to indicate the sensor not being available.""" - return super().available and self.get_sensor is not None - - -class WaterSensor(YoulessBaseSensor): - """The Youless Water sensor.""" - - _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_device_class = SensorDeviceClass.WATER - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate a Water sensor.""" - super().__init__(coordinator, device, "water", "Water meter", "water") - self._attr_name = "Water usage" - self._attr_icon = "mdi:water" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.water_meter - - -class GasSensor(YoulessBaseSensor): - """The Youless gas sensor.""" - - _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_device_class = SensorDeviceClass.GAS - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate a gas sensor.""" - super().__init__(coordinator, device, "gas", "Gas meter", "gas") - self._attr_name = "Gas usage" - self._attr_icon = "mdi:fire" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.gas_meter - - -class CurrentPowerSensor(YoulessBaseSensor): - """The current power usage sensor.""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str - ) -> None: - """Instantiate the usage meter.""" - super().__init__(coordinator, device, "power", "Power usage", "usage") - self._device = device - self._attr_name = "Power Usage" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - return self.coordinator.data.current_power_usage - - -class DeliveryMeterSensor(YoulessBaseSensor): - """The Youless delivery meter value sensor.""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate a delivery meter sensor.""" - super().__init__( - coordinator, device, "delivery", "Energy delivery", f"delivery_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Energy delivery {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.delivery_meter is None: - return None - - return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None) - - -class EnergyMeterSensor(YoulessBaseSensor): - """The Youless low meter value sensor.""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, - coordinator: DataUpdateCoordinator[YoulessAPI], - device: str, - dev_type: str, - state_class: SensorStateClass, - ) -> None: - """Instantiate a energy meter sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"power_{dev_type}" - ) - self._device = device - self._type = dev_type - self._attr_name = f"Energy {dev_type}" - self._attr_state_class = state_class - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.power_meter is None: - return None - - return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) - - -class PhasePowerSensor(YoulessBaseSensor): - """The current power usage of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the power phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_power" - ) - self._attr_name = f"Phase {phase} power" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.power - - -class PhaseVoltageSensor(YoulessBaseSensor): - """The current voltage of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the voltage phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" - ) - self._attr_name = f"Phase {phase} voltage" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator for phase voltage.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.voltage - - -class PhaseCurrentSensor(YoulessBaseSensor): - """The current current of a single phase.""" - - _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE - _attr_device_class = SensorDeviceClass.CURRENT - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int - ) -> None: - """Initialize the current phase sensor.""" - super().__init__( - coordinator, device, "power", "Energy usage", f"phase_{phase}_current" - ) - self._attr_name = f"Phase {phase} current" - self._phase = phase - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor value from the coordinator for phase current.""" - phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) - if phase_sensor is None: - return None - - return phase_sensor.current - - -class ExtraMeterSensor(YoulessBaseSensor): - """The Youless extra meter value sensor (s0).""" - - _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate an extra meter sensor.""" - super().__init__( - coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Extra {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.extra_meter is None: - return None - - return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) - - -class ExtraMeterPowerSensor(YoulessBaseSensor): - """The Youless extra meter power value sensor (s0).""" - - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, dev_type: str - ) -> None: - """Instantiate an extra meter power sensor.""" - super().__init__( - coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" - ) - self._type = dev_type - self._attr_name = f"Extra {dev_type}" - - @property - def get_sensor(self) -> YoulessSensor | None: - """Get the sensor for providing the value.""" - if self.coordinator.data.extra_meter is None: - return None - - return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) + """Return the state of the sensor.""" + return self.entity_description.value_func(self.device) diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index e0eddd7d137..c735e2b2ff2 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -14,5 +14,68 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "device": { + "water": { + "name": "Water meter" + }, + "gas": { + "name": "Gas meter" + }, + "power": { + "name": "Power meter" + }, + "delivery": { + "name": "Energy delivery meter" + }, + "extra": { + "name": "S0 meter" + } + }, + "entity": { + "sensor": { + "total_water": { + "name": "Total water usage" + }, + "total_gas_m3": { + "name": "Total gas usage" + }, + "active_power_w": { + "name": "Current power usage" + }, + "active_power_phase_w": { + "name": "Power phase {phase}" + }, + "active_voltage_phase_v": { + "name": "Voltage phase {phase}" + }, + "active_current_phase_a": { + "name": "Current phase {phase}" + }, + "active_tariff": { + "name": "Tariff" + }, + "total_energy_import_tariff_kwh": { + "name": "Energy import tariff {tariff}" + }, + "total_energy_import_kwh": { + "name": "Total energy import" + }, + "total_energy_export_tariff_kwh": { + "name": "Energy export tariff {tariff}" + }, + "total_s0_kwh": { + "name": "Total energy" + }, + "active_s0_w": { + "name": "Current usage" + }, + "average_peak": { + "name": "Average peak" + }, + "month_peak": { + "name": "Month peak" + } + } } } diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 8460a105fcb..ec8a3f325cb 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -8,11 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.device_registry as dr from .api import AsyncConfigEntryAuth from .const import AUTH, COORDINATOR, DOMAIN @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - coordinator = YouTubeDataUpdateCoordinator(hass, auth) + coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 0da480f1169..476e5bb4022 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -35,12 +35,15 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, auth: AsyncConfigEntryAuth + ) -> None: """Initialize the YouTube data coordinator.""" self._auth = auth super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=15), ) diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 8832382508c..128c23f7082 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import YouTubeDataUpdateCoordinator @@ -72,7 +72,9 @@ SENSOR_TYPES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the YouTube sensor.""" coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 05881d649cf..524bac271de 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -27,8 +27,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 7728233ebc0..27d7e71d8d9 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py index d53c743f500..a88c97ad267 100644 --- a/homeassistant/components/zamg/coordinator.py +++ b/homeassistant/components/zamg/coordinator.py @@ -32,6 +32,7 @@ class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=MIN_TIME_BETWEEN_UPDATES, ) diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 7c7f5fd6c16..5846092e555 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -163,7 +163,9 @@ API_FIELDS: list[str] = [desc.para_name for desc in SENSOR_TYPES] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZAMG sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 286a6460f19..ac376577ade 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, CONF_STATION_ID, DOMAIN, MANUFACTURER_URL @@ -20,7 +20,9 @@ from .coordinator import ZamgDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZAMG weather platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 69b7c63476a..2ab46820b56 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -18,10 +18,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 69c745c46a3..e80b6b8cfdb 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -4,9 +4,8 @@ from __future__ import annotations import contextlib from contextlib import suppress -from dataclasses import dataclass from fnmatch import translate -from functools import lru_cache +from functools import lru_cache, partial from ipaddress import IPv4Address, IPv6Address import logging import re @@ -30,12 +29,20 @@ from homeassistant.const import ( __version__, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow, instance_id +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID as _ATTR_PROPERTIES_ID, + ZeroconfServiceInfo as _ZeroconfServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, @@ -83,7 +90,11 @@ ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] -ATTR_PROPERTIES_ID: Final = "id" +_DEPRECATED_ATTR_PROPERTIES_ID = DeprecatedConstant( + _ATTR_PROPERTIES_ID, + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + "2026.2", +) CONFIG_SCHEMA = vol.Schema( { @@ -101,45 +112,11 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - -@dataclass(slots=True) -class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries. - - The ip_address is the most recently updated address - that is not a link local or unspecified address. - - The ip_addresses are all addresses in order of most - recently updated to least recently updated. - - The host is the string representation of the ip_address. - - The addresses are the string representations of the - ip_addresses. - - It is recommended to use the ip_address to determine - the address to connect to as it will be the most - recently updated address that is not a link local - or unspecified address. - """ - - ip_address: IPv4Address | IPv6Address - ip_addresses: list[IPv4Address | IPv6Address] - port: int | None - hostname: str - type: str - name: str - properties: dict[str, Any] - - @property - def host(self) -> str: - """Return the host.""" - return str(self.ip_address) - - @property - def addresses(self) -> list[str]: - """Return the addresses.""" - return [str(ip_address) for ip_address in self.ip_addresses] +_DEPRECATED_ZeroconfServiceInfo = DeprecatedConstant( + _ZeroconfServiceInfo, + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + "2026.2", +) @bind_hass @@ -164,13 +141,13 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: return _async_get_instance(hass) -def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: +def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) logging.getLogger("zeroconf").setLevel(logging.NOTSET) - zeroconf = HaZeroconf(**zcargs) + zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) install_multiple_zeroconf_catcher(zeroconf) @@ -198,12 +175,10 @@ def _async_zc_has_functional_dual_stack() -> bool: ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {"ip_version": IPVersion.V4Only} - - adapters = await network.async_get_adapters(hass) - +def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]: + """Get zeroconf arguments from config.""" + zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only} + adapters = network.async_get_loaded_adapters(hass) ipv6 = False if _async_zc_has_functional_dual_stack(): if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): @@ -218,7 +193,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: zc_args["interfaces"] = [ str(source_ip) - for source_ip in await network.async_get_enabled_source_ips(hass) + for source_ip in network.async_get_enabled_source_ips_from_adapters( + adapters + ) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) and not ( @@ -230,8 +207,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: and zc_args["ip_version"] == IPVersion.V6Only ) ] + return zc_args - aio_zc = _async_get_instance(hass, **zc_args) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Zeroconf and make Home Assistant discoverable.""" + aio_zc = _async_get_instance(hass) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) @@ -419,7 +400,7 @@ class ZeroconfDiscovery: def _async_dismiss_discoveries(self, name: str) -> None: """Dismiss all discoveries for the given name.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - ZeroconfServiceInfo, + _ZeroconfServiceInfo, lambda service_info: bool(service_info.name == name), ): self.hass.config_entries.flow.async_abort(flow["flow_id"]) @@ -595,7 +576,7 @@ def async_get_homekit_discovery( return None -def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: +def info_from_service(service: AsyncServiceInfo) -> _ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings @@ -615,10 +596,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: return None if TYPE_CHECKING: - assert ( - service.server is not None - ), "server cannot be none if there are addresses" - return ZeroconfServiceInfo( + assert service.server is not None, ( + "server cannot be none if there are addresses" + ) + return _ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, port=service.port, @@ -684,3 +665,11 @@ def _memorized_fnmatch(name: str, pattern: str) -> bool: since the devices will not change frequently """ return bool(_compile_fnmatch(pattern).match(name)) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 98fa02a716e..8abaa4a838e 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.137.2"] + "requirements": ["zeroconf==0.145.1"] } diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index ed6ed03ad27..19175ae3084 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -18,9 +18,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN @@ -51,7 +51,7 @@ async def discover_entities(hass: HomeAssistant) -> list[ZerprocLight]: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Zerproc light devices.""" warned = False diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 12831c96932..ec8850b187d 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zeversolar/coordinator.py b/homeassistant/components/zeversolar/coordinator.py index 9f6ff49eaf8..ec68cf4b56f 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -20,11 +20,14 @@ _LOGGER = logging.getLogger(__name__) class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): """Data update coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(minutes=1), ) diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index 5023e274267..330e5bb72d8 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ZeversolarCoordinator @@ -52,7 +52,9 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zeversolar sensor.""" coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1897b741d87..e446f32cf08 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,6 +12,10 @@ from zha.zigbee.device import get_device_automation_triggers from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_TYPE, @@ -21,12 +25,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType -from . import repairs, websocket_api +from . import homeassistant_hardware, repairs, websocket_api from .const import ( CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, @@ -111,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ha_zha_data = HAZHAData(yaml_config=config.get(DOMAIN, {})) hass.data[DATA_ZHA] = ha_zha_data + async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + return True @@ -219,6 +224,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_config) ) + if fw_info := homeassistant_hardware.get_firmware_info(hass, config_entry): + await async_notify_firmware_info( + hass, + DOMAIN, + firmware_info=fw_info, + ) + await ha_zha_data.gateway_proxy.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 734683e5497..ff61ce07d23 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -46,7 +46,7 @@ ZHA_STATE_TO_ALARM_STATE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation alarm control panel from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index f45ebf0c5a5..f8146026384 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -26,7 +26,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation binary sensor from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index ecd5cd51f61..dd90bcd29b1 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation button from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index af9f56cd7dc..a3f60420a38 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -30,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_TENTHS, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -66,7 +66,7 @@ ZHA_TO_HA_HVAC_ACTION = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5cb67489423..b98e53f98d8 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -14,7 +14,7 @@ from zha.application.const import RadioType import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant.components import onboarding, usb, zeroconf +from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon @@ -35,6 +35,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN @@ -111,9 +113,14 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: except HomeAssistantError: pass else: - yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1") - yellow_radio.description = "Yellow Zigbee module" - yellow_radio.manufacturer = "Nabu Casa" + # PySerial does not properly handle the Yellow's serial port with the CM5 + # so we manually include it + port = ListPortInfo(device="/dev/ttyAMA1", skip_link_detection=True) + port.description = "Yellow Zigbee module" + port.manufacturer = "Nabu Casa" + + ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")] + ports.insert(0, port) if is_hassio(hass): # Present the multi-PAN addon as a setup option, if it's available @@ -586,9 +593,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: self._title}, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" vid = discovery_info.vid pid = discovery_info.pid @@ -623,7 +628,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo + self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" @@ -649,7 +654,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): fallback_title = name.split("._", 1)[0] title = discovery_info.properties.get("name", fallback_title) - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=discovery_info.ip_address, ip_addresses=discovery_info.ip_addresses, port=port, diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0d6be2dbb35..d058f37ff6b 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation cover from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index fc374f6c44d..c86bb3352b5 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -23,7 +23,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation device tracker from config entry.""" zha_data = get_zha_data(hass) @@ -61,7 +61,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZHAEntity): """ return self.entity_data.entity.battery_level - @property # type: ignore[explicit-override, misc] + @property # type: ignore[misc] def device_info(self) -> DeviceInfo: """Return device info.""" # We opt ZHA device tracker back into overriding this method because diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 3e3d0642ca2..e3339661d15 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -8,7 +8,7 @@ from functools import partial import logging from typing import Any -from propcache import cached_property +from propcache.api import cached_property from zha.mixins import LogMixin from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory @@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" meta = self.entity_data.entity.info_object + if meta.primary: + self._attr_name = None + return super().name + original_name = super().name if original_name not in (UNDEFINED, None) or meta.fallback_name is None: @@ -87,7 +91,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.state.node_info.ieee), + via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)), ) @callback diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 73b23e97387..81206f8819e 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -27,7 +27,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation fan from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 2440e18cf53..700e2833705 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -11,6 +11,7 @@ import enum import functools import itertools import logging +import queue import re import time from types import MappingProxyType @@ -111,9 +112,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType +from homeassistant.util.logging import HomeAssistantQueueHandler from .const import ( ATTR_ACTIVE_COORDINATOR, @@ -505,7 +507,14 @@ class ZHAGatewayProxy(EventBase): DEBUG_LEVEL_CURRENT: async_capture_log_levels(), } self.debug_enabled: bool = False - self._log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self) + + log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self) + log_simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue() + self._log_queue_handler = HomeAssistantQueueHandler(log_simple_queue) + self._log_queue_handler.listener = logging.handlers.QueueListener( + log_simple_queue, log_relay_handler + ) + self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) self._reload_task: asyncio.Task | None = None @@ -736,10 +745,13 @@ class ZHAGatewayProxy(EventBase): self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() if filterer: - self._log_relay_handler.addFilter(filterer) + self._log_queue_handler.addFilter(filterer) + + if self._log_queue_handler.listener: + self._log_queue_handler.listener.start() for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).addHandler(self._log_relay_handler) + logging.getLogger(logger_name).addHandler(self._log_queue_handler) self.debug_enabled = True @@ -749,9 +761,14 @@ class ZHAGatewayProxy(EventBase): async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL]) self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).removeHandler(self._log_relay_handler) + logging.getLogger(logger_name).removeHandler(self._log_queue_handler) + + if self._log_queue_handler.listener: + self._log_queue_handler.listener.stop() + if filterer: - self._log_relay_handler.removeFilter(filterer) + self._log_queue_handler.removeFilter(filterer) + self.debug_enabled = False async def shutdown(self) -> None: @@ -978,7 +995,7 @@ class LogRelayHandler(logging.Handler): entry = LogEntry( record, self.paths_re, figure_out_source=record.levelno >= logging.WARNING ) - async_dispatcher_send( + dispatcher_send( self.hass, ZHA_GW_MSG, {ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()}, @@ -1170,7 +1187,7 @@ def async_add_entities( # broad exception to prevent a single entity from preventing an entire platform from loading # this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the # alternative is adding try/catch to each entity class __init__ method with a specific exception - except Exception: # noqa: BLE001 + except Exception: _LOGGER.exception( "Error while adding entity from entity data: %s", entity_data ) diff --git a/homeassistant/components/zha/homeassistant_hardware.py b/homeassistant/components/zha/homeassistant_hardware.py new file mode 100644 index 00000000000..18057d3b64d --- /dev/null +++ b/homeassistant/components/zha/homeassistant_hardware.py @@ -0,0 +1,43 @@ +"""Home Assistant Hardware firmware utilities.""" + +from __future__ import annotations + +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .helpers import get_zha_gateway + + +@callback +def get_firmware_info( + hass: HomeAssistant, config_entry: ConfigEntry +) -> FirmwareInfo | None: + """Return firmware information for the ZHA instance, synchronously.""" + + # We only support EZSP firmware for now + if config_entry.data.get("radio_type", None) != "ezsp": + return None + + if (device := config_entry.data.get("device", {}).get("path")) is None: + return None + + try: + gateway = get_zha_gateway(hass) + except ValueError: + firmware_version = None + else: + firmware_version = gateway.state.node_info.version + + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.EZSP, + firmware_version=firmware_version, + source=DOMAIN, + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + ) diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index 6ba4aab18ab..d43e213aa4a 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -124,6 +124,12 @@ }, "on_led_color": { "default": "mdi:palette" + }, + "device_mode": { + "default": "mdi:cogs" + }, + "pilot_wire_mode": { + "default": "mdi:radiator" } }, "sensor": { diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2f5d9e9e4c9..a2fb61dc019 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .entity import ZHAEntity @@ -59,7 +59,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation light from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index ebac03eb7b8..dc27ec7a6fa 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -33,7 +33,7 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 3de81e1255d..05539a063d2 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -10,7 +10,7 @@ from zha.application.const import ZHA_EVENT from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN as ZHA_DOMAIN from .helpers import async_get_zha_device_proxy diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index f9323fe99df..0cc2524469e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.45"], + "requirements": ["zha==0.0.51"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 263f5262994..567e2a5b37a 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UndefinedType from .entity import ZHAEntity @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Analog Output from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 82c30b7678a..6a5d39bc3db 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -29,6 +29,7 @@ from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import repairs from .const import ( @@ -86,7 +87,7 @@ HARDWARE_MIGRATION_SCHEMA = vol.Schema( vol.Required("old_discovery_info"): vol.Schema( { vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA, - vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo, + vol.Exclusive("usb", "discovery"): UsbServiceInfo, } ), } @@ -419,7 +420,7 @@ class ZhaMultiPANMigrationHelper: self._radio_mgr.radio_type = new_radio_type self._radio_mgr.device_path = new_device_settings[CONF_DEVICE_PATH] self._radio_mgr.device_settings = new_device_settings - device_settings = self._radio_mgr.device_settings.copy() # type: ignore[union-attr] + device_settings = self._radio_mgr.device_settings.copy() # Update the config entry settings self._hass.config_entries.async_update_entry( diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index fdb47b550fe..4a38738b7dd 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index dde000b24b5..a8383857e57 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .entity import ZHAEntity @@ -43,10 +43,18 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { "measurement_type", "apparent_power_max", "rms_current_max", + "rms_current_max_ph_b", + "rms_current_max_ph_c", "rms_voltage_max", + "rms_voltage_max_ph_b", + "rms_voltage_max_ph_c", "ac_frequency_max", "power_factor_max", + "power_factor_max_ph_b", + "power_factor_max_ph_c", "active_power_max", + "active_power_max_ph_b", + "active_power_max_ph_c", # Smart Energy metering "device_type", "status", @@ -72,7 +80,7 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 9d876d9ca4d..0c8b447cb37 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -26,7 +26,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -41,7 +41,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index da76c62e82e..be1642227bd 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name}", "step": { "choose_serial_port": { - "title": "Select a Serial Port", + "title": "Select a serial port", + "description": "Select the serial port for your Zigbee radio", "data": { - "path": "Serial Device Path" - }, - "description": "Select the serial port for your Zigbee radio" + "path": "Serial device path" + } }, "confirm": { "description": "Do you want to set up {name}?" @@ -16,14 +16,14 @@ "description": "Do you want to set up {name}?" }, "manual_pick_radio_type": { + "title": "Select a radio type", + "description": "Pick your Zigbee radio type", "data": { - "radio_type": "Radio Type" - }, - "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]", - "description": "Pick your Zigbee radio type" + "radio_type": "Radio type" + } }, "manual_port_config": { - "title": "Serial Port Settings", + "title": "Serial port settings", "description": "Enter the serial port settings", "data": { "path": "Serial device path", @@ -36,7 +36,7 @@ "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." }, "choose_formation_strategy": { - "title": "Network Formation", + "title": "Network formation", "description": "Choose the network settings for your radio.", "menu_options": { "form_new_network": "Erase network settings and create a new network", @@ -47,21 +47,21 @@ } }, "choose_automatic_backup": { - "title": "Restore Automatic Backup", + "title": "Restore automatic backup", "description": "Restore your network settings from an automatic backup", "data": { "choose_automatic_backup": "Choose an automatic backup" } }, "upload_manual_backup": { - "title": "Upload a Manual Backup", + "title": "Upload a manual backup", "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "data": { "uploaded_backup_file": "Upload a file" } }, "maybe_confirm_ezsp_restore": { - "title": "Overwrite Radio IEEE Address", + "title": "Overwrite radio IEEE address", "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "data": { "overwrite_coordinator_ieee": "Permanently replace the radio IEEE address" @@ -74,10 +74,10 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "not_zha_device": "This device is not a zha device", - "usb_probe_failed": "Failed to probe the usb device", + "not_zha_device": "This device is not a ZHA device", + "usb_probe_failed": "Failed to probe the USB device", "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", - "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA" + "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA" } }, "options": { @@ -274,15 +274,15 @@ }, "source_ieee": { "name": "Source IEEE", - "description": "IEEE address of the joining device (must be used with the install code)." + "description": "IEEE address of the joining device (must be combined with the 'Install code' field)." }, "install_code": { "name": "Install code", - "description": "Install code of the joining device (must be used with the source_ieee)." + "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)." }, "qr_code": { "name": "QR code", - "description": "Value of the QR install code (different between vendors)." + "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)." } } }, @@ -307,7 +307,7 @@ } }, "set_zigbee_cluster_attribute": { - "name": "Set zigbee cluster attribute", + "name": "Set Zigbee cluster attribute", "description": "Sets an attribute value for the specified cluster on the specified entity.", "fields": { "ieee": { @@ -323,7 +323,7 @@ "description": "ZCL cluster to retrieve attributes for." }, "cluster_type": { - "name": "Cluster Type", + "name": "Cluster type", "description": "Type of the cluster." }, "attribute": { @@ -341,7 +341,7 @@ } }, "issue_zigbee_cluster_command": { - "name": "Issue zigbee cluster command", + "name": "Issue Zigbee cluster command", "description": "Issues a command on the specified cluster on the specified entity.", "fields": { "ieee": { @@ -383,8 +383,8 @@ } }, "issue_zigbee_group_command": { - "name": "Issue zigbee group command", - "description": "Issue command on the specified cluster on the specified group.", + "name": "Issue Zigbee group command", + "description": "Issues a command on the specified cluster on the specified group.", "fields": { "group": { "name": "Group", @@ -592,6 +592,24 @@ }, "window_detection": { "name": "Open window detection" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "preheat_active": { + "name": "Preheat active" + }, + "fault_alarm": { + "name": "Fault alarm" + }, + "led_indicator": { + "name": "LED indicator" + }, + "error_or_battery_low": { + "name": "Error or battery low" + }, + "flow_switch": { + "name": "Flow switch" } }, "button": { @@ -612,6 +630,9 @@ }, "restart_device": { "name": "Restart device" + }, + "frost_lock_reset": { + "name": "Frost lock reset" } }, "climate": { @@ -885,6 +906,201 @@ }, "fading_time": { "name": "Fading time" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "humidity_offset": { + "name": "Humidity offset" + }, + "comfort_temperature_min": { + "name": "Comfort temperature min" + }, + "comfort_temperature_max": { + "name": "Comfort temperature max" + }, + "comfort_humidity_min": { + "name": "Comfort humidity min" + }, + "comfort_humidity_max": { + "name": "Comfort humidity max" + }, + "measurement_interval": { + "name": "Measurement interval" + }, + "on_time": { + "name": "On time" + }, + "alarm_duration": { + "name": "Alarm duration" + }, + "max_set": { + "name": "Liquid max percentage" + }, + "mini_set": { + "name": "Liquid minimal percentage" + }, + "installation_height": { + "name": "Height from sensor to tank bottom" + }, + "liquid_depth_max": { + "name": "Height from sensor to liquid level" + }, + "interval_time": { + "name": "Interval time" + }, + "target_distance": { + "name": "Target distance" + }, + "hold_delay_time": { + "name": "Hold delay time" + }, + "breath_detection_max": { + "name": "Breath detection max" + }, + "breath_detection_min": { + "name": "Breath detection min" + }, + "small_move_detection_max": { + "name": "Small move detection max" + }, + "small_move_detection_min": { + "name": "Small move detection min" + }, + "small_move_sensitivity": { + "name": "Small move sensitivity" + }, + "breath_sensitivity": { + "name": "Breath sensitivity" + }, + "entry_sensitivity": { + "name": "Entry sensitivity" + }, + "entry_distance_indentation": { + "name": "Entry distance indentation" + }, + "illuminance_threshold": { + "name": "Illuminance threshold" + }, + "block_time": { + "name": "Block time" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "radar_sensitivity": { + "name": "Radar sensitivity" + }, + "motionless_detection": { + "name": "Motionless detection" + }, + "motionless_sensitivity": { + "name": "Motionless detection sensitivity" + }, + "output_time": { + "name": "Output time" + }, + "illuminance_interval": { + "name": "Illuminance interval" + }, + "temperature_report_interval": { + "name": "Temperature report interval" + }, + "humidity_report_interval": { + "name": "Humidity report interval" + }, + "alarm_temperature_max": { + "name": "Alarm temperature max" + }, + "alarm_temperature_min": { + "name": "Alarm temperature min" + }, + "temperature_sensitivity": { + "name": "Temperature sensitivity" + }, + "alarm_humidity_max": { + "name": "Alarm humidity max" + }, + "alarm_humidity_min": { + "name": "Alarm humidity min" + }, + "humidity_sensitivity": { + "name": "Humidity sensitivity" + }, + "deadzone_temperature": { + "name": "Deadzone temperature" + }, + "min_temperature": { + "name": "Min temperature" + }, + "max_temperature": { + "name": "Max temperature" + }, + "valve_countdown": { + "name": "Irrigation time" + }, + "quantitative_watering": { + "name": "Quantitative watering" + }, + "valve_duration": { + "name": "Irrigation duration" + }, + "down_movement": { + "name": "Down movement" + }, + "sustain_time": { + "name": "Sustain time" + }, + "up_movement": { + "name": "Up movement" + }, + "large_motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "large_motion_detection_distance": { + "name": "Motion detection distance" + }, + "medium_motion_detection_distance": { + "name": "Medium motion detection distance" + }, + "medium_motion_detection_sensitivity": { + "name": "Medium motion detection sensitivity" + }, + "small_motion_detection_distance": { + "name": "Small motion detection distance" + }, + "small_motion_detection_sensitivity": { + "name": "Small motion detection sensitivity" + }, + "static_detection_sensitivity": { + "name": "Static detection sensitivity" + }, + "static_detection_distance": { + "name": "Static detection distance" + }, + "motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "holiday_temperature": { + "name": "Holiday temperature" + }, + "boost_time": { + "name": "Boost time" + }, + "antifrost_temperature": { + "name": "Antifrost temperature" + }, + "eco_temperature": { + "name": "Eco temperature" + }, + "comfort_temperature": { + "name": "Comfort temperature" + }, + "valve_state_auto_shutdown": { + "name": "Valve state auto shutdown" + }, + "shutdown_timer": { + "name": "Shutdown timer" } }, "select": { @@ -1028,9 +1244,90 @@ }, "operation_mode": { "name": "Operation mode" + }, + "device_mode": { + "name": "Device mode" + }, + "pilot_wire_mode": { + "name": "Pilot wire mode" + }, + "alarm_ringtone": { + "name": "Alarm ringtone" + }, + "liquid_state": { + "name": "Liquid state" + }, + "breaker_mode": { + "name": "Breaker mode" + }, + "breaker_status": { + "name": "Breaker status" + }, + "status_indication": { + "name": "Status indication" + }, + "breaker_polarity": { + "name": "Breaker polarity" + }, + "work_mode": { + "name": "Work mode" + }, + "presence_sensitivity": { + "name": "Presence sensitivity" + }, + "fading_time": { + "name": "Fading time" + }, + "display_unit": { + "name": "Display unit" + }, + "alarm_mode": { + "name": "Alarm mode" + }, + "alarm_volume": { + "name": "Alarm volume" + }, + "working_day": { + "name": "Working day" + }, + "eco_mode": { + "name": "Eco mode" + }, + "mode": { + "name": "Mode" + }, + "reverse": { + "name": "Reverse" + }, + "motion_state": { + "name": "Motion state" + }, + "motion_detection_mode": { + "name": "Motion detection mode" + }, + "screen_orientation": { + "name": "Screen orientation" + }, + "motor_thrust": { + "name": "Motor thrust" + }, + "display_brightness": { + "name": "Display brightness" + }, + "display_orientation": { + "name": "Display orientation" + }, + "hysteresis_mode": { + "name": "Hysteresis mode" } }, "sensor": { + "active_power_ph_b": { + "name": "Power phase B" + }, + "active_power_ph_c": { + "name": "Power phase C" + }, "analog_input": { "name": "Analog input" }, @@ -1046,6 +1343,24 @@ "instantaneous_demand": { "name": "Instantaneous demand" }, + "power_factor_ph_b": { + "name": "Power factor phase B" + }, + "power_factor_ph_c": { + "name": "Power factor phase C" + }, + "rms_current_ph_b": { + "name": "Current phase B" + }, + "rms_current_ph_c": { + "name": "Current phase C" + }, + "rms_voltage_ph_b": { + "name": "Voltage phase B" + }, + "rms_voltage_ph_c": { + "name": "Voltage phase C" + }, "summation_delivered": { "name": "Summation delivered" }, @@ -1246,6 +1561,111 @@ }, "self_test": { "name": "Self test result" + }, + "voc_index": { + "name": "VOC index" + }, + "energy_ph_a": { + "name": "Energy phase A" + }, + "energy_ph_b": { + "name": "Energy phase B" + }, + "energy_ph_c": { + "name": "Energy phase C" + }, + "energy_produced": { + "name": "Energy produced" + }, + "energy_produced_ph_a": { + "name": "Energy produced phase A" + }, + "energy_produced_ph_b": { + "name": "Energy produced phase B" + }, + "energy_produced_ph_c": { + "name": "Energy produced phase C" + }, + "total_power_factor": { + "name": "Total power factor" + }, + "self_test_result": { + "name": "Self test result" + }, + "lower_explosive_limit": { + "name": "% Lower explosive limit" + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level_percent": { + "name": "Liquid level ratio" + }, + "target_distance": { + "name": "Target distance" + }, + "human_motion_state": { + "name": "Human motion state" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "alarm_state": { + "name": "Alarm state" + }, + "power_type": { + "name": "Power type" + }, + "valve_position": { + "name": "Valve position" + }, + "time_left": { + "name": "Time left" + }, + "valve_status": { + "name": "Valve status" + }, + "valve_duration": { + "name": "Irrigation duration" + }, + "smart_irrigation": { + "name": "Smart irrigation" + }, + "surplus_flow": { + "name": "Surplus flow" + }, + "single_watering_duration": { + "name": "Single watering duration" + }, + "single_watering_amount": { + "name": "Single watering amount" + }, + "error_status": { + "name": "Error status" + }, + "brightness_level": { + "name": "Brightness level" + }, + "average_light_intensity_20mins": { + "name": "Average light intensity last 20 min" + }, + "todays_max_light_intensity": { + "name": "Today's max light intensity" + }, + "fault_code": { + "name": "Fault code" + }, + "water_flow": { + "name": "Water flow" + }, + "remaining_watering_time": { + "name": "Remaining watering time" + }, + "last_watering_duration": { + "name": "Last watering duration" } }, "switch": { @@ -1374,6 +1794,87 @@ }, "find_switch": { "name": "Distance switch" + }, + "display_enabled": { + "name": "Display enabled" + }, + "show_smiley": { + "name": "Show smiley" + }, + "on_only_when_dark": { + "name": "On only when dark" + }, + "mute_siren": { + "name": "Mute siren" + }, + "self_test_switch": { + "name": "Self test" + }, + "output_switch": { + "name": "Output switch" + }, + "siren_on": { + "name": "Siren on" + }, + "enable_tamper_alarm": { + "name": "Enable tamper alarm" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "frost_protection": { + "name": "Frost protection" + }, + "factory_reset": { + "name": "Factory reset" + }, + "away_mode": { + "name": "Away mode" + }, + "schedule_enable": { + "name": "Schedule enable" + }, + "scale_protection": { + "name": "Scale protection" + }, + "frost_lock": { + "name": "Frost lock" + }, + "switch_enabled": { + "name": "Switch enabled" + }, + "total_flow_reset_switch": { + "name": "Total flow reset switch" + }, + "touch_control": { + "name": "Touch control" + }, + "sound_enabled": { + "name": "Sound enabled" + }, + "invert_relay": { + "name": "Invert relay" + }, + "boost_heating": { + "name": "Boost heating" + }, + "holiday_mode": { + "name": "Holiday mode" + }, + "heating_stop": { + "name": "Heating stop" + }, + "schedule_mode": { + "name": "Schedule mode" + }, + "auto_clean": { + "name": "Auto clean" } } } diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index cb0268f98e0..dc150e2407d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation switch from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index cb5c160e7b3..062581fd259 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -19,7 +19,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -52,7 +52,7 @@ OTA_MESSAGE_RELIABILITY = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation update from config entry.""" zha_data = get_zha_data(hass) @@ -124,7 +124,7 @@ class ZHAFirmwareUpdateEntity( return self.entity_data.entity.installed_version @property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Should return a boolean (True if in progress, False if not). @@ -163,11 +163,7 @@ class ZHAFirmwareUpdateEntity( """ if self.entity_data.device_proxy.device.is_mains_powered: - header = ( - "" - f"{OTA_MESSAGE_RELIABILITY}" - "" - ) + header = f"{OTA_MESSAGE_RELIABILITY}" else: header = ( "" diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 5ffd7117d93..07d897bcfd6 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -37,6 +37,7 @@ from zha.application.const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ZHA_CLUSTER_HANDLER_MSG, + ZHA_GW_MSG, ) from zha.application.gateway import Gateway from zha.application.helpers import ( @@ -59,8 +60,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import VolDictType, VolSchemaType @@ -331,7 +331,7 @@ async def websocket_permit_devices( connection.send_message(websocket_api.event_message(msg["id"], data)) remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages + hass, ZHA_GW_MSG, forward_messages ) @callback diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index b5acc230472..af3287d3068 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index 6e858b454e9..fe180208801 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index d7cac07a322..41f200366ae 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local, utcnow from .const import ( @@ -150,7 +150,7 @@ ZODIAC_BY_DATE = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 1c43a79e10e..813425c95f2 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -363,7 +363,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from storage.""" zone = cls(config) zone.editable = True - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @classmethod @@ -371,7 +371,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from yaml.""" zone = cls(config) zone.editable = False - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @property diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index aa4aefe6d95..af4999e5438 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -85,11 +85,8 @@ async def async_attach_trigger( from_s = zone_event.data["old_state"] to_s = zone_event.data["new_state"] - if ( - from_s - and not location.has_location(from_s) - or to_s - and not location.has_location(to_s) + if (from_s and not location.has_location(from_s)) or ( + to_s and not location.has_location(to_s) ): return @@ -107,13 +104,8 @@ async def async_attach_trigger( from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, to_s) if to_s else False - if ( - event == EVENT_ENTER - and not from_match - and to_match - or event == EVENT_LEAVE - and from_match - and not to_match + if (event == EVENT_ENTER and not from_match and to_match) or ( + event == EVENT_LEAVE and from_match and not to_match ): description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" hass.async_run_hass_job( diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index e87a2b1531d..c2e57b0448b 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 75769d9fd98..4f79f8876e5 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 23adf2f4c88..13da0927196 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 1a1cd6ae9c1..aef23cb73ea 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -70,9 +70,8 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from .config_validation import BITMASK_SCHEMA @@ -806,7 +805,7 @@ async def websocket_add_node( ] msg[DATA_UNSUBSCRIBE] = unsubs - if controller.inclusion_state == InclusionState.INCLUDING: + if controller.inclusion_state in (InclusionState.INCLUDING, InclusionState.BUSY): connection.send_result( msg[ID], True, # Inclusion is already in progress @@ -884,6 +883,11 @@ async def websocket_subscribe_s2_inclusion( ) -> None: """Subscribe to S2 inclusion initiated by the controller.""" + @callback + def async_cleanup() -> None: + for unsub in unsubs: + unsub() + @callback def forward_dsk(event: dict) -> None: connection.send_message( @@ -892,9 +896,18 @@ async def websocket_subscribe_s2_inclusion( ) ) - unsub = driver.controller.on("validate dsk and enter pin", forward_dsk) - connection.subscriptions[msg["id"]] = unsub - msg[DATA_UNSUBSCRIBE] = [unsub] + @callback + def handle_requested_grant(event: dict) -> None: + """Accept the requested security classes without user interaction.""" + hass.async_create_task( + driver.controller.async_grant_security_classes(event["requested_grant"]) + ) + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + driver.controller.on("grant security classes", handle_requested_grant), + driver.controller.on("validate dsk and enter pin", forward_dsk), + ] connection.send_result(msg[ID]) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 0f1495fc6e6..d07846c8dcc 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -261,7 +261,7 @@ def is_valid_notification_binary_sensor( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave binary sensor from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 7fd42700a05..f3a1d5af04d 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave button from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 580694cae11..b27dbdad1a0 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -35,7 +35,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from .const import DATA_CLIENT, DOMAIN @@ -97,7 +97,7 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave climate from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 711eb14070d..44adf6a12ab 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.components.hassio import ( AddonManager, AddonState, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ( SOURCE_USB, ConfigEntriesFlowManager, @@ -39,6 +38,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType from . import disconnect_client @@ -405,9 +406,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): }, ) - async def async_step_usb( - self, discovery_info: usb.UsbServiceInfo - ) -> ConfigFlowResult: + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" if not is_hassio(self.hass): return self.async_abort(reason="discovery_requires_supervisor") diff --git a/homeassistant/components/zwave_js/config_validation.py b/homeassistant/components/zwave_js/config_validation.py index 30bc2f16789..2615bfc72b3 100644 --- a/homeassistant/components/zwave_js/config_validation.py +++ b/homeassistant/components/zwave_js/config_validation.py @@ -4,7 +4,7 @@ from typing import Any import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv # Validates that a bitmask is provided in hex form and converts it to decimal # int equivalent since that's what the library uses diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 218c5cc82fe..dc44f46a3ce 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -37,7 +37,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( COVER_POSITION_PROPERTY_KEYS, @@ -55,7 +55,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Cover from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py index 8dae66c26ac..66959aa9b75 100644 --- a/homeassistant/components/zwave_js/event.py +++ b/homeassistant/components/zwave_js/event.py @@ -10,7 +10,7 @@ from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Event entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index d83132e4b95..ae36e0afb42 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -46,7 +46,7 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Fan from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5885527e01c..904a26acc78 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -154,16 +154,8 @@ async def async_enable_server_logging_if_needed( LOGGER.info("Enabling zwave-js-server logging") if (curr_server_log_level := driver.log_config.level) and ( LOG_LEVEL_MAP[curr_server_log_level] - ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): + ) > LIB_LOGGER.getEffectiveLevel(): entry_data = entry.runtime_data - LOGGER.warning( - ( - "Server logging is set to %s and is currently less verbose " - "than library logging, setting server log level to %s to match" - ), - curr_server_log_level, - logging.getLevelName(lib_log_level), - ) entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) await driver.client.enable_server_logging() diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index e883858036b..2b85bd4449f 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -26,7 +26,7 @@ from homeassistant.components.humidifier import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -70,7 +70,7 @@ DEHUMIDIFIER_ENTITY_DESCRIPTION = ZwaveHumidifierEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave humidifier from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index e6cfc6c8b29..a610bbcb91e 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -41,8 +41,8 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import color as color_util from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -67,7 +67,7 @@ MAX_MIREDS = 370 # 2700K as a safe default async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Light from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @@ -458,7 +458,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if warm_white and cool_white: self._supports_color_temp = True # only one white channel (warm white or cool white) = rgbw support - elif red and green and blue and warm_white or cool_white: + elif (red and green and blue and warm_white) or cool_white: self._supports_rgbw = True @callback diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index c14517f4b03..f609084955c 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_AUTO_RELOCK_TIME, @@ -62,7 +62,7 @@ UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave lock from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 315793b9726..120084788e1 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -9,7 +9,7 @@ from zwave_js_server.const import CommandClass from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_COMMAND_CLASS, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 011776f4556..3178bdf46ad 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 54162488d89..2e2d93bbdbe 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Number entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 49ad1868005..8a6ccc57c17 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Select entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index b259711d21b..4db14d003b1 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -48,7 +48,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from .binary_sensor import is_valid_notification_binary_sensor @@ -552,7 +552,7 @@ def get_entity_description( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index d1cb66ceafc..8389eff8cb2 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -29,8 +29,11 @@ from zwave_js_server.util.node import ( from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.group import expand_entity_ids @@ -488,10 +491,7 @@ class ZWaveServices: ) if nodes_without_endpoints and _LOGGER.isEnabledFor(logging.WARNING): _LOGGER.warning( - ( - "The following nodes do not have endpoint %x and will be " - "skipped: %s" - ), + "The following nodes do not have endpoint %x and will be skipped: %s", endpoint, nodes_without_endpoints, ) diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 3a09049def3..f0526171a70 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -18,7 +18,7 @@ from homeassistant.components.siren import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Siren entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index fc63b7e9119..8f23fee4447 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,28 +1,28 @@ { "config": { "abort": { - "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "addon_info_failed": "Failed to get Z-Wave JS add-on info.", - "addon_install_failed": "Failed to install the Z-Wave JS add-on.", - "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", - "addon_start_failed": "Failed to start the Z-Wave JS add-on.", + "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.", + "addon_info_failed": "Failed to get Z-Wave add-on info.", + "addon_install_failed": "Failed to install the Z-Wave add-on.", + "addon_set_config_failed": "Failed to set Z-Wave configuration.", + "addon_start_failed": "Failed to start the Z-Wave add-on.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "discovery_requires_supervisor": "Discovery requires the supervisor.", "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on." }, "error": { - "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", + "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + "install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds." }, "step": { "configure_addon": { @@ -34,13 +34,13 @@ "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "The add-on will generate security keys if those fields are left empty.", - "title": "Enter the Z-Wave JS add-on configuration" + "title": "Enter the Z-Wave add-on configuration" }, "hassio_confirm": { - "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" + "title": "Set up Z-Wave integration with the Z-Wave add-on" }, "install_addon": { - "title": "The Z-Wave JS add-on installation has started" + "title": "The Z-Wave add-on installation has started" }, "manual": { "data": { @@ -49,20 +49,20 @@ }, "on_supervisor": { "data": { - "use_addon": "Use the Z-Wave JS Supervisor add-on" + "use_addon": "Use the Z-Wave Supervisor add-on" }, - "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "description": "Do you want to use the Z-Wave Supervisor add-on?", "title": "Select connection method" }, "start_addon": { - "title": "The Z-Wave JS add-on is starting." + "title": "The Z-Wave add-on is starting." }, "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave JS add-on?" + "description": "Do you want to set up {name} with the Z-Wave add-on?" }, "zeroconf_confirm": { - "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", - "title": "Discovered Z-Wave JS Server" + "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", + "title": "Discovered Z-Wave Server" } } }, @@ -89,7 +89,7 @@ "event.value_notification.scene_activation": "Scene Activation on {subtype}", "state.node_status": "Node status changed", "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", - "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" + "zwave_js.value_updated.value": "Value change on a Z-Wave Value" }, "extra_fields": { "code_slot": "Code slot", @@ -191,7 +191,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" @@ -203,8 +203,8 @@ "title": "Device configuration file changed: {device_name}" }, "invalid_server_version": { - "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue.", - "title": "Newer version of Z-Wave JS Server needed" + "description": "The version of Z-Wave Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave Server to the latest version to fix this issue.", + "title": "Newer version of Z-Wave Server needed" } }, "options": { @@ -306,7 +306,7 @@ "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` action and require direct calls to the Command Class API.", "fields": { "area_id": { - "description": "The area(s) to target for this action. If an area is specified, all zwave_js devices and entities in that area will be targeted for this action.", + "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.", "name": "Area ID(s)" }, "command_class": { @@ -326,26 +326,26 @@ "name": "Entity ID(s)" }, "method_name": { - "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", + "description": "The name of the API method to call. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", "name": "Method name" }, "parameters": { - "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", + "description": "A list of parameters to pass to the API method. Refer to the Z-Wave Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", "name": "Parameters" } }, "name": "Invoke a Command Class API on a node (advanced)" }, "multicast_set_value": { - "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.", + "description": "Changes any value that Z-Wave recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This action has minimal validation so only use this action if you know what you are doing.", "fields": { "area_id": { "description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]", "name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]" }, "broadcast": { - "description": "Whether command should be broadcast to all devices on the network.", - "name": "Broadcast?" + "description": "Whether the command should be broadcast to all devices on the network.", + "name": "Broadcast" }, "command_class": { "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]", @@ -383,7 +383,7 @@ "name": "Set a value on multiple devices via multicast (advanced)" }, "ping": { - "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", + "description": "Forces Z-Wave to try to reach a node. This can be used to update the status of the node in Z-Wave when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", "fields": { "area_id": { "description": "[%key:component::zwave_js::services::set_value::fields::area_id::description%]", @@ -434,8 +434,8 @@ "name": "Entities" }, "refresh_all_values": { - "description": "Whether to refresh all values (true) or just the primary value (false).", - "name": "Refresh all values?" + "description": "Whether to refresh all values or just the primary value.", + "name": "Refresh all values" } }, "name": "Refresh values" @@ -474,7 +474,7 @@ "name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]" }, "bitmask": { - "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format.", + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with 'Value size' or 'Value format'.", "name": "Bitmask" }, "device_id": { @@ -498,11 +498,11 @@ "name": "Value" }, "value_format": { - "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with 'Value size' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.", "name": "Value format" }, "value_size": { - "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "description": "Size of the value, either 1, 2, or 4. Used in combination with 'Value format' when a config parameter is not defined in your device's configuration file. Cannot be combined with 'Bitmask'.", "name": "Value size" } }, @@ -516,8 +516,8 @@ "name": "Auto relock time" }, "block_to_block": { - "description": "Enable block-to-block functionality.", - "name": "Block to block" + "description": "Whether the lock should run the motor until it hits resistance.", + "name": "Block to Block" }, "hold_and_release_time": { "description": "Duration in seconds the latch stays retracted.", @@ -529,11 +529,11 @@ }, "operation_type": { "description": "The operation type of the lock.", - "name": "Operation Type" + "name": "Operation type" }, "twist_assist": { - "description": "Enable Twist Assist.", - "name": "Twist assist" + "description": "Whether the motor should help in locking and unlocking.", + "name": "Twist Assist" } }, "name": "Set lock configuration" @@ -553,10 +553,10 @@ "name": "Set lock user code" }, "set_value": { - "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", + "description": "Changes any value that Z-Wave recognizes on a Z-Wave device. This action has minimal validation so only use this action if you know what you are doing.", "fields": { "area_id": { - "description": "The area(s) to target for this action. If an area is specified, all zwave_js devices and entities in that area will be targeted for this action.", + "description": "The area(s) to target for this action. If an area is specified, all Z-Wave devices and entities in that area will be targeted for this action.", "name": "Area ID(s)" }, "command_class": { @@ -576,7 +576,7 @@ "name": "Entity ID(s)" }, "options": { - "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set.", + "description": "Set value options map. Refer to the Z-Wave documentation for more information on what options can be set.", "name": "Options" }, "property": { @@ -592,8 +592,8 @@ "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" }, "wait_for_result": { - "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the action can take a while if setting a value on an asleep battery device.", - "name": "Wait for result?" + "description": "Whether to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If enabled, the action can take a while if setting a value on an asleep battery device.", + "name": "Wait for result" } }, "name": "Set a value (advanced)" diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index ef769209b31..2ff80d8505e 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index d060abe007d..985c4a86813 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -32,7 +32,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData @@ -77,7 +77,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave update entity from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_me/binary_sensor.py b/homeassistant/components/zwave_me/binary_sensor.py index d121c17770b..8563ef76ce1 100644 --- a/homeassistant/components/zwave_me/binary_sensor.py +++ b/homeassistant/components/zwave_me/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform @@ -33,7 +33,7 @@ DEVICE_NAME = ZWaveMePlatform.BINARY_SENSOR async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py index 50ddf01aeab..27d95a14199 100644 --- a/homeassistant/components/zwave_me/button.py +++ b/homeassistant/components/zwave_me/button.py @@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -15,7 +15,7 @@ DEVICE_NAME = ZWaveMePlatform.BUTTON async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index b8eed88b505..d54cc6a9310 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -28,7 +28,7 @@ DEVICE_NAME = ZWaveMePlatform.CLIMATE async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the climate platform.""" diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index 1444bfc1b95..d37d76a093b 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -7,9 +7,9 @@ import logging from url_normalize import url_normalize import voluptuous as vol -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import helpers from .const import DOMAIN diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index c9359402c01..3ae8ec894e1 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -23,7 +23,7 @@ DEVICE_NAME = ZWaveMePlatform.COVER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cover platform.""" diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index bd0feba0dfb..6ab1df618cb 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -8,7 +8,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -19,7 +19,7 @@ DEVICE_NAME = ZWaveMePlatform.FAN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fan platform.""" diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index ef3eca5d389..f8ed397ea25 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform @@ -25,7 +25,7 @@ from .entity import ZWaveMeEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the rgb platform.""" diff --git a/homeassistant/components/zwave_me/lock.py b/homeassistant/components/zwave_me/lock.py index 0bcc8f092ae..cdc8b6471c1 100644 --- a/homeassistant/components/zwave_me/lock.py +++ b/homeassistant/components/zwave_me/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -21,7 +21,7 @@ DEVICE_NAME = ZWaveMePlatform.LOCK async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the lock platform.""" diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index 9a98a4f8d00..2d6b88840f4 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -4,7 +4,7 @@ from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -15,7 +15,7 @@ DEVICE_NAME = ZWaveMePlatform.NUMBER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index be0b0bae284..fa9ccdfee99 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform @@ -118,7 +118,7 @@ DEVICE_NAME = ZWaveMePlatform.SENSOR async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/zwave_me/siren.py b/homeassistant/components/zwave_me/siren.py index 443b2cc7b37..7bfbf2b2cd4 100644 --- a/homeassistant/components/zwave_me/siren.py +++ b/homeassistant/components/zwave_me/siren.py @@ -6,7 +6,7 @@ from homeassistant.components.siren import SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -17,7 +17,7 @@ DEVICE_NAME = ZWaveMePlatform.SIREN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the siren platform.""" diff --git a/homeassistant/components/zwave_me/switch.py b/homeassistant/components/zwave_me/switch.py index 05cf06484e9..26d832ca022 100644 --- a/homeassistant/components/zwave_me/switch.py +++ b/homeassistant/components/zwave_me/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -30,7 +30,7 @@ SWITCH_MAP: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ade4cd855ca..bfea2c29eac 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -15,6 +15,7 @@ from collections.abc import ( ) from contextvars import ContextVar from copy import deepcopy +from dataclasses import dataclass, field from datetime import datetime from enum import Enum, StrEnum import functools @@ -22,11 +23,10 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Generic, Self, cast +from typing import TYPE_CHECKING, Any, Self, TypedDict, cast from async_interrupt import interrupt -from propcache import cached_property -from typing_extensions import TypeVar +from propcache.api import cached_property import voluptuous as vol from . import data_entry_flow, loader @@ -88,12 +88,12 @@ from .util.enum import try_parse_enum if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak - from .components.dhcp import DhcpServiceInfo - from .components.ssdp import SsdpServiceInfo - from .components.usb import UsbServiceInfo - from .components.zeroconf import ZeroconfServiceInfo + from .helpers.service_info.dhcp import DhcpServiceInfo from .helpers.service_info.hassio import HassioServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + from .helpers.service_info.ssdp import SsdpServiceInfo + from .helpers.service_info.usb import UsbServiceInfo + from .helpers.service_info.zeroconf import ZeroconfServiceInfo _LOGGER = logging.getLogger(__name__) @@ -128,7 +128,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry() STORAGE_KEY = "core.config_entries" STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 SAVE_DELAY = 1 @@ -137,8 +137,6 @@ DISCOVERY_COOLDOWN = 1 ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision" UNIQUE_ID_COLLISION_TITLE_LIMIT = 5 -_DataT = TypeVar("_DataT", default=Any) - class ConfigEntryState(Enum): """Config entry state.""" @@ -157,6 +155,8 @@ class ConfigEntryState(Enum): """An error occurred when trying to unload the entry""" SETUP_IN_PROGRESS = "setup_in_progress", False """The config entry is setting up.""" + UNLOAD_IN_PROGRESS = "unload_in_progress", False + """The config entry is being unloaded.""" _recoverable: bool @@ -256,6 +256,10 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" +class UnknownSubEntry(ConfigError): + """Unknown subentry specified.""" + + class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" @@ -300,6 +304,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): minor_version: int options: Mapping[str, Any] + subentries: Iterable[ConfigSubentryData] version: int @@ -313,7 +318,62 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) -class ConfigEntry(Generic[_DataT]): +class ConfigSubentryData(TypedDict): + """Container for configuration subentry data. + + Returned by integrations, a subentry_id will be assigned automatically. + """ + + data: Mapping[str, Any] + subentry_type: str + title: str + unique_id: str | None + + +class ConfigSubentryDataWithId(ConfigSubentryData): + """Container for configuration subentry data. + + This type is used when loading existing subentries from storage. + """ + + subentry_id: str + + +class SubentryFlowContext(FlowContext, total=False): + """Typed context dict for subentry flow.""" + + entry_id: str + subentry_id: str + + +class SubentryFlowResult(FlowResult[SubentryFlowContext, tuple[str, str]], total=False): + """Typed result dict for subentry flow.""" + + unique_id: str | None + + +@dataclass(frozen=True, kw_only=True) +class ConfigSubentry: + """Container for a configuration subentry.""" + + data: MappingProxyType[str, Any] + subentry_id: str = field(default_factory=ulid_util.ulid_now) + subentry_type: str + title: str + unique_id: str | None + + def as_dict(self) -> ConfigSubentryDataWithId: + """Return dictionary version of this subentry.""" + return { + "data": dict(self.data), + "subentry_id": self.subentry_id, + "subentry_type": self.subentry_type, + "title": self.title, + "unique_id": self.unique_id, + } + + +class ConfigEntry[_DataT = Any]: """Hold a configuration entry.""" entry_id: str @@ -322,6 +382,7 @@ class ConfigEntry(Generic[_DataT]): data: MappingProxyType[str, Any] runtime_data: _DataT options: MappingProxyType[str, Any] + subentries: MappingProxyType[str, ConfigSubentry] unique_id: str | None state: ConfigEntryState reason: str | None @@ -337,9 +398,11 @@ class ConfigEntry(Generic[_DataT]): supports_remove_device: bool | None _supports_options: bool | None _supports_reconfigure: bool | None + _supported_subentry_types: dict[str, dict[str, bool]] | None update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None + _on_state_change: list[CALLBACK_TYPE] | None setup_lock: asyncio.Lock _reauth_lock: asyncio.Lock _tasks: set[asyncio.Future[Any]] @@ -366,6 +429,7 @@ class ConfigEntry(Generic[_DataT]): pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, + subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None, title: str, unique_id: str | None, version: int, @@ -391,6 +455,25 @@ class ConfigEntry(Generic[_DataT]): # Entry options _setter(self, "options", MappingProxyType(options or {})) + # Subentries + subentries_data = subentries_data or () + subentries = {} + for subentry_data in subentries_data: + subentry_kwargs = {} + if "subentry_id" in subentry_data: + # If subentry_data has key "subentry_id", we're loading from storage + subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item] + subentry = ConfigSubentry( + data=MappingProxyType(subentry_data["data"]), + subentry_type=subentry_data["subentry_type"], + title=subentry_data["title"], + unique_id=subentry_data.get("unique_id"), + **subentry_kwargs, + ) + subentries[subentry.subentry_id] = subentry + + _setter(self, "subentries", MappingProxyType(subentries)) + # Entry system options if pref_disable_new_entities is None: pref_disable_new_entities = False @@ -427,6 +510,9 @@ class ConfigEntry(Generic[_DataT]): # Supports reconfigure _setter(self, "_supports_reconfigure", None) + # Supports subentries + _setter(self, "_supported_subentry_types", None) + # Listeners to call on update _setter(self, "update_listeners", []) @@ -441,6 +527,9 @@ class ConfigEntry(Generic[_DataT]): # Hold list for actions to call on unload. _setter(self, "_on_unload", None) + # Hold list for actions to call on state change. + _setter(self, "_on_state_change", None) + # Reload lock to prevent conflicting reloads _setter(self, "setup_lock", asyncio.Lock()) # Reauth lock to prevent concurrent reauth flows @@ -499,6 +588,28 @@ class ConfigEntry(Generic[_DataT]): ) return self._supports_reconfigure or False + @property + def supported_subentry_types(self) -> dict[str, dict[str, bool]]: + """Return supported subentry types.""" + if self._supported_subentry_types is None and ( + handler := HANDLERS.get(self.domain) + ): + # work out sub entries supported by the handler + supported_flows = handler.async_get_supported_subentry_types(self) + object.__setattr__( + self, + "_supported_subentry_types", + { + subentry_flow_type: { + "supports_reconfigure": hasattr( + subentry_flow_handler, "async_step_reconfigure" + ) + } + for subentry_flow_type, subentry_flow_handler in supported_flows.items() + }, + ) + return self._supported_subentry_types or {} + def clear_state_cache(self) -> None: """Clear cached properties that are included in as_json_fragment.""" self.__dict__.pop("as_json_fragment", None) @@ -518,12 +629,14 @@ class ConfigEntry(Generic[_DataT]): "supports_remove_device": self.supports_remove_device or False, "supports_unload": self.supports_unload or False, "supports_reconfigure": self.supports_reconfigure, + "supported_subentry_types": self.supported_subentry_types, "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "disabled_by": self.disabled_by, "reason": self.reason, "error_reason_translation_key": self.error_reason_translation_key, "error_reason_translation_placeholders": self.error_reason_translation_placeholders, + "num_subentries": len(self.subentries), } return json_fragment(json_bytes(json_repr)) @@ -691,10 +804,7 @@ class ConfigEntry(Generic[_DataT]): self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( - ( - "Config entry '%s' for %s integration not %s; Retrying in %d" - " seconds" - ), + "Config entry '%s' for %s integration not %s; Retrying in %d seconds", self.title, self.domain, ready_message, @@ -851,18 +961,25 @@ class ConfigEntry(Generic[_DataT]): ) return False + if domain_is_integration: + self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) try: result = await component.async_unload_entry(hass, self) assert isinstance(result, bool) - # Only adjust state if we unloaded the component - if domain_is_integration and result: - await self._async_process_on_unload(hass) - if hasattr(self, "runtime_data"): - object.__delattr__(self, "runtime_data") + # Only do side effects if we unloaded the integration + if domain_is_integration: + if result: + await self._async_process_on_unload(hass) + if hasattr(self, "runtime_data"): + object.__delattr__(self, "runtime_data") - self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + else: + self._async_set_state( + hass, ConfigEntryState.FAILED_UNLOAD, "Unload failed" + ) except Exception as exc: _LOGGER.exception( @@ -945,6 +1062,8 @@ class ConfigEntry(Generic[_DataT]): hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self ) + self._async_process_on_state_change() + async def async_migrate(self, hass: HomeAssistant) -> bool: """Migrate an entry. @@ -1018,6 +1137,7 @@ class ConfigEntry(Generic[_DataT]): "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "source": self.source, + "subentries": [subentry.as_dict() for subentry in self.subentries.values()], "title": self.title, "unique_id": self.unique_id, "version": self.version, @@ -1058,6 +1178,28 @@ class ConfigEntry(Generic[_DataT]): task, ) + @callback + def async_on_state_change(self, func: CALLBACK_TYPE) -> CALLBACK_TYPE: + """Add a function to call when a config entry changes its state.""" + if self._on_state_change is None: + self._on_state_change = [] + self._on_state_change.append(func) + return lambda: cast(list, self._on_state_change).remove(func) + + def _async_process_on_state_change(self) -> None: + """Process the on_state_change callbacks and wait for pending tasks.""" + if self._on_state_change is None: + return + for func in self._on_state_change: + try: + func() + except Exception: + _LOGGER.exception( + "Error calling on_state_change callback for %s (%s)", + self.title, + self.domain, + ) + @callback def async_start_reauth( self, @@ -1486,6 +1628,23 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) + if existing_entry is not None and flow.handler != "mobile_app": + # This causes the old entry to be removed and replaced, when the flow + # should instead be aborted. + # In case of manual flows, integrations should implement options, reauth, + # reconfigure to allow the user to change settings. + # In case of non user visible flows, the integration should optionally + # update the existing entry before aborting. + # see https://developers.home-assistant.io/blog/2025/03/01/config-flow-unique-id/ + report_usage( + "creates a config entry when another entry with the same unique ID " + "exists", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + integration_domain=flow.handler, + ) + # Unload the entry before setting up the new one. if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) @@ -1503,6 +1662,7 @@ class ConfigEntriesFlowManager( minor_version=result["minor_version"], options=result["options"], source=flow.context["source"], + subentries_data=result["subentries"], title=result["title"], unique_id=flow.unique_id, version=result["version"], @@ -1793,6 +1953,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entry in data["entries"]: entry["discovery_keys"] = {} + if old_minor_version < 5: + # Version 1.4 adds config subentries + for entry in data["entries"]: + entry.setdefault("subentries", entry.get("subentries", {})) + if old_major_version > 1: raise NotImplementedError return data @@ -1809,6 +1974,7 @@ class ConfigEntries: self.hass = hass self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) + self.subentries = ConfigSubentryFlowManager(hass) self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) @@ -1840,7 +2006,7 @@ class ConfigEntries: Raises UnknownEntry if entry is not found. """ if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + raise UnknownEntry(entry_id) return entry @callback @@ -1890,7 +2056,7 @@ class ConfigEntries: def async_loaded_entries(self, domain: str) -> list[ConfigEntry]: """Return loaded entries for a specific domain. - This will exclude ignored or disabled config entruis. + This will exclude ignored or disabled config entries. """ entries = self._entries.get_entries_for_domain(domain) @@ -1940,9 +2106,9 @@ class ConfigEntries: else: unload_success = await self.async_unload(entry_id, _lock=False) + del self._entries[entry.entry_id] await entry.async_remove(self.hass) - del self._entries[entry.entry_id] self.async_update_issues() self._async_schedule_save() @@ -2011,6 +2177,7 @@ class ConfigEntries: pref_disable_new_entities=entry["pref_disable_new_entities"], pref_disable_polling=entry["pref_disable_polling"], source=entry["source"], + subentries_data=entry["subentries"], title=entry["title"], unique_id=entry["unique_id"], version=entry["version"], @@ -2170,6 +2337,44 @@ class ConfigEntries: If the entry was changed, the update_listeners are fired and this function returns True + If the entry was not changed, the update_listeners are + not fired and this function returns False + """ + return self._async_update_entry( + entry, + data=data, + discovery_keys=discovery_keys, + minor_version=minor_version, + options=options, + pref_disable_new_entities=pref_disable_new_entities, + pref_disable_polling=pref_disable_polling, + title=title, + unique_id=unique_id, + version=version, + ) + + @callback + def _async_update_entry( + self, + entry: ConfigEntry, + *, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] + | UndefinedType = UNDEFINED, + minor_version: int | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + pref_disable_new_entities: bool | UndefinedType = UNDEFINED, + pref_disable_polling: bool | UndefinedType = UNDEFINED, + subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + version: int | UndefinedType = UNDEFINED, + ) -> bool: + """Update a config entry. + + If the entry was changed, the update_listeners are + fired and this function returns True + If the entry was not changed, the update_listeners are not fired and this function returns False """ @@ -2232,11 +2437,21 @@ class ConfigEntries: changed = True _setter(entry, "options", MappingProxyType(options)) + if subentries is not UNDEFINED: + if entry.subentries != subentries: + changed = True + _setter(entry, "subentries", MappingProxyType(subentries)) + if not changed: return False _setter(entry, "modified_at", utcnow()) + self._async_save_and_notify(entry) + return True + + @callback + def _async_save_and_notify(self, entry: ConfigEntry) -> None: for listener in entry.update_listeners: self.hass.async_create_task( listener(self.hass, entry), @@ -2247,8 +2462,92 @@ class ConfigEntries: entry.clear_state_cache() entry.clear_storage_cache() self._async_dispatch(ConfigEntryChange.UPDATED, entry) + + @callback + def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool: + """Add a subentry to a config entry.""" + self._raise_if_subentry_unique_id_exists(entry, subentry.unique_id) + + return self._async_update_entry( + entry, + subentries=entry.subentries | {subentry.subentry_id: subentry}, + ) + + @callback + def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool: + """Remove a subentry from a config entry.""" + subentries = dict(entry.subentries) + try: + subentries.pop(subentry_id) + except KeyError as err: + raise UnknownSubEntry from err + + result = self._async_update_entry(entry, subentries=subentries) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) + + dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + return result + + @callback + def async_update_subentry( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + ) -> bool: + """Update a config subentry. + + If the subentry was changed, the update_listeners are + fired and this function returns True + + If the subentry was not changed, the update_listeners are + not fired and this function returns False + """ + if entry.entry_id not in self._entries: + raise UnknownEntry(entry.entry_id) + if subentry.subentry_id not in entry.subentries: + raise UnknownSubEntry(subentry.subentry_id) + + self.hass.verify_event_loop_thread("hass.config_entries.async_update_subentry") + changed = False + _setter = object.__setattr__ + + if unique_id is not UNDEFINED and subentry.unique_id != unique_id: + self._raise_if_subentry_unique_id_exists(entry, unique_id) + changed = True + _setter(subentry, "unique_id", unique_id) + + if title is not UNDEFINED and subentry.title != title: + changed = True + _setter(subentry, "title", title) + + if data is not UNDEFINED and subentry.data != data: + changed = True + _setter(subentry, "data", MappingProxyType(data)) + + if not changed: + return False + + _setter(entry, "modified_at", utcnow()) + + self._async_save_and_notify(entry) return True + def _raise_if_subentry_unique_id_exists( + self, entry: ConfigEntry, unique_id: str | None + ) -> None: + """Raise if a subentry with the same unique_id exists.""" + if unique_id is None: + return + for existing_subentry in entry.subentries.values(): + if existing_subentry.unique_id == unique_id: + raise data_entry_flow.AbortFlow("already_configured") + @callback def _async_dispatch( self, change_type: ConfigEntryChange, entry: ConfigEntry @@ -2585,6 +2884,14 @@ class ConfigFlow(ConfigEntryBaseFlow): """Return options flow support for this handler.""" return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {} + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -2893,6 +3200,7 @@ class ConfigFlow(ConfigEntryBaseFlow): description: str | None = None, description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, + subentries: Iterable[ConfigSubentryData] | None = None, ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: @@ -2912,6 +3220,7 @@ class ConfigFlow(ConfigEntryBaseFlow): result["minor_version"] = self.MINOR_VERSION result["options"] = options or {} + result["subentries"] = subentries or () result["version"] = self.VERSION return result @@ -3026,17 +3335,199 @@ class ConfigFlow(ConfigEntryBaseFlow): ) -class OptionsFlowManager( - data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult] -): - """Flow to set options for a configuration entry.""" +class _ConfigSubFlowManager: + """Mixin class for flow managers which manage flows tied to a config entry.""" - _flow_result = ConfigFlowResult + hass: HomeAssistant def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: """Return config entry or raise if not found.""" return self.hass.config_entries.async_get_known_entry(config_entry_id) + +class ConfigSubentryFlowManager( + data_entry_flow.FlowManager[ + SubentryFlowContext, SubentryFlowResult, tuple[str, str] + ], + _ConfigSubFlowManager, +): + """Manage all the config subentry flows that are in progress.""" + + _flow_result = SubentryFlowResult + + async def async_create_flow( + self, + handler_key: tuple[str, str], + *, + context: FlowContext | None = None, + data: dict[str, Any] | None = None, + ) -> ConfigSubentryFlow: + """Create a subentry flow for a config entry. + + The entry_id and flow.handler[0] is the same thing to map entry with flow. + """ + if not context or "source" not in context: + raise KeyError("Context not set or doesn't have a source set") + + entry_id, subentry_type = handler_key + entry = self._async_get_config_entry(entry_id) + handler = await _async_get_flow_handler(self.hass, entry.domain, {}) + subentry_types = handler.async_get_supported_subentry_types(entry) + if subentry_type not in subentry_types: + raise data_entry_flow.UnknownHandler( + f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'" + ) + subentry_flow = subentry_types[subentry_type]() + subentry_flow.init_step = context["source"] + return subentry_flow + + async def async_finish_flow( + self, + flow: data_entry_flow.FlowHandler[ + SubentryFlowContext, SubentryFlowResult, tuple[str, str] + ], + result: SubentryFlowResult, + ) -> SubentryFlowResult: + """Finish a subentry flow and add a new subentry to the configuration entry. + + The flow.handler[0] and entry_id is the same thing to map flow with entry. + """ + flow = cast(ConfigSubentryFlow, flow) + + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + return result + + entry_id, subentry_type = flow.handler + entry = self.hass.config_entries.async_get_entry(entry_id) + if entry is None: + raise UnknownEntry(entry_id) + + unique_id = result.get("unique_id") + if unique_id is not None and not isinstance(unique_id, str): + raise HomeAssistantError("unique_id must be a string") + + self.hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(result["data"]), + subentry_type=subentry_type, + title=result["title"], + unique_id=unique_id, + ), + ) + + result["result"] = True + return result + + +class ConfigSubentryFlow( + data_entry_flow.FlowHandler[ + SubentryFlowContext, SubentryFlowResult, tuple[str, str] + ] +): + """Base class for config subentry flows.""" + + _flow_result = SubentryFlowResult + handler: tuple[str, str] + + @callback + def async_create_entry( + self, + *, + title: str | None = None, + data: Mapping[str, Any], + description: str | None = None, + description_placeholders: Mapping[str, str] | None = None, + unique_id: str | None = None, + ) -> SubentryFlowResult: + """Finish config flow and create a config entry.""" + if self.source != SOURCE_USER: + raise ValueError(f"Source is {self.source}, expected {SOURCE_USER}") + + result = super().async_create_entry( + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) + + result["unique_id"] = unique_id + + return result + + @callback + def async_update_and_abort( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + ) -> SubentryFlowResult: + """Update config subentry and finish subentry flow. + + :param data: replace the subentry data with new data + :param data_updates: add items from data_updates to subentry data - existing + keys are overridden + :param title: replace the title of the subentry + :param unique_id: replace the unique_id of the subentry + """ + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = subentry.data | data_updates + self.hass.config_entries.async_update_subentry( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + ) + return self.async_abort(reason="reconfigure_successful") + + @property + def _reconfigure_entry_id(self) -> str: + """Return reconfigure entry id.""" + if self.source != SOURCE_RECONFIGURE: + raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + return self.handler[0] + + @callback + def _get_reconfigure_entry(self) -> ConfigEntry: + """Return the reconfigure config entry linked to the current context.""" + return self.hass.config_entries.async_get_known_entry( + self._reconfigure_entry_id + ) + + @property + def _reconfigure_subentry_id(self) -> str: + """Return reconfigure subentry id.""" + if self.source != SOURCE_RECONFIGURE: + raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + return self.context["subentry_id"] + + @callback + def _get_reconfigure_subentry(self) -> ConfigSubentry: + """Return the reconfigure config subentry linked to the current context.""" + entry = self.hass.config_entries.async_get_known_entry( + self._reconfigure_entry_id + ) + subentry_id = self._reconfigure_subentry_id + if subentry_id not in entry.subentries: + raise UnknownSubEntry(subentry_id) + return entry.subentries[subentry_id] + + +class OptionsFlowManager( + data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult], + _ConfigSubFlowManager, +): + """Manage all the config entry option flows that are in progress.""" + + _flow_result = ConfigFlowResult + async def async_create_flow( self, handler_key: str, @@ -3046,7 +3537,7 @@ class OptionsFlowManager( ) -> OptionsFlow: """Create an options flow for a config entry. - Entry_id and flow.handler is the same thing to map entry with flow. + The entry_id and the flow.handler is the same thing to map entry with flow. """ entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) @@ -3062,7 +3553,7 @@ class OptionsFlowManager( This method is called when a flow step returns FlowResultType.ABORT or FlowResultType.CREATE_ENTRY. - Flow.handler and entry_id is the same thing to map flow with entry. + The flow.handler and the entry_id is the same thing to map flow with entry. """ flow = cast(OptionsFlow, flow) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4c4d2fa90c2..b9695c350a7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,14 +24,14 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 2 +MINOR_VERSION: Final = 4 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -632,6 +632,15 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" +# Energy Distance units +class UnitOfEnergyDistance(StrEnum): + """Energy Distance units.""" + + KILO_WATT_HOUR_PER_100_KM = "kWh/100km" + MILES_PER_KILO_WATT_HOUR = "mi/kWh" + KM_PER_KILO_WATT_HOUR = "km/kWh" + + # Electric_current units class UnitOfElectricCurrent(StrEnum): """Electric current units.""" @@ -647,6 +656,8 @@ class UnitOfElectricPotential(StrEnum): MICROVOLT = "µV" MILLIVOLT = "mV" VOLT = "V" + KILOVOLT = "kV" + MEGAVOLT = "MV" # Degree units diff --git a/homeassistant/core.py b/homeassistant/core.py index da7a206b14e..46ae499e2ca 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -36,12 +36,12 @@ from typing import ( NotRequired, Self, TypedDict, + TypeVar, cast, overload, ) -from propcache import cached_property, under_cached_property -from typing_extensions import TypeVar +from propcache.api import cached_property, under_cached_property import voluptuous as vol from . import util @@ -332,7 +332,7 @@ class HassJob[**_P, _R_co]: we run the job. """ - __slots__ = ("target", "name", "_cancel_on_shutdown", "_cache") + __slots__ = ("_cache", "_cancel_on_shutdown", "name", "target") def __init__( self, @@ -1153,8 +1153,7 @@ class HomeAssistant: await self.async_block_till_done() except TimeoutError: _LOGGER.warning( - "Timed out waiting for integrations to stop, the shutdown will" - " continue" + "Timed out waiting for integrations to stop, the shutdown will continue" ) self._async_log_running_tasks("stop integrations") @@ -1247,7 +1246,7 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("id", "user_id", "parent_id", "origin_event", "_cache") + __slots__ = ("_cache", "id", "origin_event", "parent_id", "user_id") def __init__( self, @@ -1322,12 +1321,12 @@ class Event(Generic[_DataT]): """Representation of an event within the bus.""" __slots__ = ( - "event_type", + "_cache", + "context", "data", + "event_type", "origin", "time_fired_timestamp", - "context", - "_cache", ) def __init__( @@ -1768,18 +1767,18 @@ class State: """ __slots__ = ( - "entity_id", - "state", + "_cache", "attributes", + "context", + "domain", + "entity_id", "last_changed", "last_reported", "last_updated", - "context", - "state_info", - "domain", - "object_id", "last_updated_timestamp", - "_cache", + "object_id", + "state", + "state_info", ) def __init__( @@ -2067,7 +2066,7 @@ class States(UserDict[str, State]): class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") + __slots__ = ("_bus", "_loop", "_reservations", "_states", "_states_data") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" @@ -2405,7 +2404,7 @@ class SupportsResponse(enum.StrEnum): class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema", "domain", "service", "supports_response"] + __slots__ = ["domain", "job", "schema", "service", "supports_response"] def __init__( self, @@ -2432,7 +2431,7 @@ class Service: class ServiceCall: """Representation of a call to a service.""" - __slots__ = ("hass", "domain", "service", "data", "context", "return_response") + __slots__ = ("context", "data", "domain", "hass", "return_response", "service") def __init__( self, @@ -2465,7 +2464,7 @@ class ServiceCall: class ServiceRegistry: """Offer the services over the eventbus.""" - __slots__ = ("_services", "_hass") + __slots__ = ("_hass", "_services") def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 6df77443e7e..251e22e7990 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -12,9 +12,8 @@ from dataclasses import dataclass from enum import StrEnum import logging from types import MappingProxyType -from typing import Any, Generic, Required, TypedDict, cast +from typing import Any, Generic, Required, TypedDict, TypeVar, cast -from typing_extensions import TypeVar import voluptuous as vol from .core import HomeAssistant, callback @@ -562,7 +561,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): if not hasattr(flow, method): self._async_remove_flow_progress(flow.flow_id) raise UnknownStep( - f"Handler {self.__class__.__name__} doesn't support step {step_id}" + f"Handler {flow.__class__.__name__} doesn't support step {step_id}" ) async def _async_setup_preview( diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 85fe55277fa..0b2d2c071c5 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -15,9 +15,9 @@ if TYPE_CHECKING: _function_cache: dict[str, Callable[[str, str, dict[str, str] | None], str]] = {} -def import_async_get_exception_message() -> ( - Callable[[str, str, dict[str, str] | None], str] -): +def import_async_get_exception_message() -> Callable[ + [str, str, dict[str, str] | None], str +]: """Return a method that can fetch a translated exception message. Defaults to English, requires translations to already be cached. @@ -174,7 +174,7 @@ class ConditionErrorIndex(ConditionError): """Yield an indented representation.""" if self.total > 1: yield self._indent( - indent, f"In '{self.type}' (item {self.index+1} of {self.total}):" + indent, f"In '{self.type}' (item {self.index + 1} of {self.total}):" ) else: yield self._indent(indent, f"In '{self.type}':") diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6b3028826dc..b891e807a7f 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [ "geocaching", "google", "google_assistant_sdk", + "google_drive", "google_mail", "google_photos", "google_sheets", @@ -24,8 +25,10 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "onedrive", "point", "senz", + "smartthings", "spotify", "tesla_fleet", "twitch", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index e592d14405b..587fea8b941 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -187,11 +187,21 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GV5126*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GV5179*", + }, { "connectable": False, "domain": "govee_ble", "local_name": "GVH5127*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GVH5130*", + }, { "connectable": False, "domain": "govee_ble", @@ -429,10 +439,6 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "led_ble", "local_name": "AP-*", }, - { - "domain": "led_ble", - "local_name": "MELK-*", - }, { "domain": "led_ble", "local_name": "LD-0003", @@ -682,6 +688,15 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "manufacturer_id": 17, "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "thermobeacon", + "manufacturer_data_start": [ + 0, + ], + "manufacturer_id": 20, + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "thermobeacon", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 14061d2e960..8284f77ef94 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ "derivative", + "filter", "generic_hygrostat", "generic_thermostat", "group", @@ -78,6 +79,7 @@ FLOWS = { "azure_data_explorer", "azure_devops", "azure_event_hub", + "azure_storage", "baf", "balboa", "bang_olufsen", @@ -230,6 +232,7 @@ FLOWS = { "google", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_photos", @@ -287,6 +290,7 @@ FLOWS = { "inkbird", "insteon", "intellifire", + "iometer", "ios", "iotawatt", "iotty", @@ -331,6 +335,7 @@ FLOWS = { "leaone", "led_ble", "lektrico", + "letpot", "lg_netcast", "lg_soundbar", "lg_thinq", @@ -357,6 +362,7 @@ FLOWS = { "mailgun", "mastodon", "matter", + "mcp", "mcp_server", "mealie", "meater", @@ -415,6 +421,7 @@ FLOWS = { "niko_home_control", "nina", "nmap_tracker", + "nmbs", "nobo_hub", "nordpool", "notion", @@ -431,6 +438,7 @@ FLOWS = { "omnilogic", "oncue", "ondilo_ico", + "onedrive", "onewire", "onkyo", "onvif", @@ -460,6 +468,7 @@ FLOWS = { "peco", "pegel_online", "permobil", + "pglab", "philips_js", "pi_hole", "picnic", @@ -487,6 +496,7 @@ FLOWS = { "pvpc_hourly_pricing", "pyload", "qbittorrent", + "qbus", "qingping", "qnap", "qnap_qsw", @@ -537,6 +547,7 @@ FLOWS = { "sensirion_ble", "sensorpro", "sensorpush", + "sensorpush_cloud", "sensoterra", "sentry", "senz", @@ -565,6 +576,7 @@ FLOWS = { "smlight", "sms", "snapcast", + "snoo", "snooz", "solaredge", "solarlog", @@ -681,6 +693,7 @@ FLOWS = { "weatherflow", "weatherflow_cloud", "weatherkit", + "webdav", "webmin", "webostv", "weheat", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 67531ceced8..3dba5a98f3c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -61,6 +61,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "axis-e82725*", "macaddress": "E82725*", }, + { + "domain": "balboa", + "registered_devices": True, + }, + { + "domain": "balboa", + "macaddress": "001527*", + }, { "domain": "blink", "hostname": "blink*", @@ -253,6 +261,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "incomfort", + "hostname": "rfgateway", + "macaddress": "0004A3*", + }, + { + "domain": "incomfort", + "registered_devices": True, + }, { "domain": "insteon", "macaddress": "000EF3*", @@ -599,6 +616,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hub*", "macaddress": "286D97*", }, + { + "domain": "smlight", + "registered_devices": True, + }, { "domain": "solaredge", "hostname": "target", @@ -1111,6 +1132,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "unifiprotect", "macaddress": "74ACB9*", }, + { + "domain": "velux", + "hostname": "velux_klf*", + "macaddress": "646184*", + }, { "domain": "verisure", "macaddress": "0023C1*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 768443c36ee..a92311d31d0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -850,6 +850,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "burbank_water_and_power": { + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" + }, "caldav": { "name": "CalDAV", "integration_type": "hub", @@ -2041,6 +2046,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "frankever": { + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" + }, "free_mobile": { "name": "Free Mobile", "integration_type": "hub", @@ -2295,6 +2305,12 @@ "iot_class": "cloud_push", "name": "Google Cloud" }, + "google_drive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Drive" + }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, @@ -2515,6 +2531,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "heicko": { + "name": "Heicko", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "heiwa": { "name": "Heiwa", "integration_type": "virtual", @@ -2598,7 +2619,8 @@ "name": "Home Connect", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "single_config_entry": true }, "home_plus_control": { "name": "Legrand Home+ Control", @@ -2860,7 +2882,7 @@ "iot_class": "local_polling" }, "incomfort": { - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" @@ -2918,6 +2940,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "iometer": { + "name": "IOmeter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ios": { "name": "Home Assistant iOS", "integration_type": "hub", @@ -3303,6 +3331,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "letpot": { + "name": "LetPot", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "leviton": { "name": "Leviton", "iot_standards": [ @@ -3334,7 +3368,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_push", - "name": "LG webOS Smart TV" + "name": "LG webOS TV" } } }, @@ -3368,12 +3402,22 @@ "config_flow": false, "iot_class": "assumed_state" }, + "linak": { + "name": "LINAK", + "integration_type": "virtual", + "supported_by": "idasen_desk" + }, "linear_garage_door": { "name": "Linear Garage Door", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" }, + "linkedgo": { + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "linkplay": { "name": "LinkPlay", "integration_type": "hub", @@ -3398,6 +3442,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "linx": { + "name": "Linx", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "lirc": { "name": "LIRC", "integration_type": "hub", @@ -3601,6 +3650,12 @@ "config_flow": true, "iot_class": "local_push" }, + "mcp": { + "name": "Model Context Protocol", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "mcp_server": { "name": "Model Context Protocol Server", "integration_type": "service", @@ -3760,6 +3815,12 @@ "iot_class": "cloud_push", "name": "Azure Service Bus" }, + "azure_storage": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Azure Storage" + }, "microsoft_face_detect": { "integration_type": "hub", "config_flow": false, @@ -3790,6 +3851,12 @@ "iot_class": "cloud_push", "name": "Microsoft Teams" }, + "onedrive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "OneDrive" + }, "xbox": { "integration_type": "hub", "config_flow": true, @@ -4218,7 +4285,7 @@ "nmbs": { "name": "NMBS", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "no_ip": { @@ -4698,6 +4765,13 @@ "integration_type": "virtual", "supported_by": "opower" }, + "pglab": { + "name": "PG LAB Electronics", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true + }, "philips": { "name": "Philips", "integrations": { @@ -4975,6 +5049,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "qbus": { + "name": "Qbus", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "qingping": { "name": "Qingping", "integration_type": "hub", @@ -5528,9 +5608,20 @@ }, "sensorpush": { "name": "SensorPush", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "integrations": { + "sensorpush": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "SensorPush" + }, + "sensorpush_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "SensorPush Cloud" + } + } }, "sensoterra": { "name": "Sensoterra", @@ -5771,6 +5862,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "smart_rollos": { + "name": "Smart Rollos", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "smarther": { "name": "Smarther", "integration_type": "virtual", @@ -5841,6 +5937,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "snoo": { + "name": "Happiest Baby Snoo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "snooz": { "name": "Snooz", "integration_type": "hub", @@ -6706,6 +6808,11 @@ "integration_type": "virtual", "supported_by": "overkiz" }, + "ublockout": { + "name": "Ublockout", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "uk_transport": { "name": "UK Transport", "integration_type": "hub", @@ -7006,6 +7113,12 @@ } } }, + "webdav": { + "name": "WebDAV", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "webmin": { "name": "Webmin", "integration_type": "device", @@ -7406,7 +7519,7 @@ }, "filter": { "integration_type": "helper", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "generic_hygrostat": { diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index f73388b203c..c4eb8708b0e 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,14 @@ MQTT = { "fully_kiosk": [ "fully/deviceInfo/+", ], + "pglab": [ + "pglab/discovery/#", + ], + "qbus": [ + "cloudapp/QBUSMQTTGW/state", + "cloudapp/QBUSMQTTGW/config", + "cloudapp/QBUSMQTTGW/+/state", + ], "tasmota": [ "tasmota/discovery/#", ], diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 89d1aa30cb8..5bbc178ba17 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -211,6 +211,12 @@ SSDP = { { "st": "nanoleaf:nl52", }, + { + "st": "nanoleaf:nl69", + }, + { + "st": "inanoleaf:nl81", + }, ], "netgear": [ { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 0766e1ce011..cc1683a3603 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -208,6 +208,14 @@ HOMEKIT = { "always_discover": False, "domain": "nanoleaf", }, + "NL69": { + "always_discover": False, + "domain": "nanoleaf", + }, + "NL81": { + "always_discover": False, + "domain": "nanoleaf", + }, "Netatmo Relay": { "always_discover": True, "domain": "netatmo", @@ -522,6 +530,11 @@ ZEROCONF = { "domain": "homekit", }, ], + "_homewizard._tcp.local.": [ + { + "domain": "homewizard", + }, + ], "_hscp._tcp.local.": [ { "domain": "apple_tv", @@ -609,6 +622,11 @@ ZEROCONF = { "domain": "homewizard", }, ], + "_iometer._tcp.local.": [ + { + "domain": "iometer", + }, + ], "_ipp._tcp.local.": [ { "domain": "ipp", @@ -729,6 +747,11 @@ ZEROCONF = { "domain": "octoprint", }, ], + "_owserver._tcp.local.": [ + { + "domain": "onewire", + }, + ], "_plexmediasvr._tcp.local.": [ { "domain": "plex", @@ -780,6 +803,11 @@ ZEROCONF = { "domain": "russound_rio", }, ], + "_shelly._tcp.local.": [ + { + "domain": "shelly", + }, + ], "_sideplay._tcp.local.": [ { "domain": "ecobee", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b5f5ee9a961..3d8dc247857 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -15,7 +15,7 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout -from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver +from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver from homeassistant import config_entries from homeassistant.components import zeroconf @@ -377,5 +377,5 @@ def _async_get_connector( @callback -def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver: - return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) +def _async_make_resolver(hass: HomeAssistant) -> AsyncDualMDNSResolver: + return AsyncDualMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index f74296a9fb1..5601ce4032d 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TypedDict +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType @@ -27,9 +28,9 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with dataclasses - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property else: - from propcache import under_cached_property + from propcache.api import under_cached_property DATA_REGISTRY: HassKey[AreaRegistry] = HassKey("area_registry") @@ -38,7 +39,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType ) STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 7 +STORAGE_VERSION_MINOR = 8 class _AreaStoreData(TypedDict): @@ -46,11 +47,13 @@ class _AreaStoreData(TypedDict): aliases: list[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: list[str] name: str picture: str | None + temperature_entity_id: str | None created_at: str modified_at: str @@ -74,10 +77,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): aliases: set[str] floor_id: str | None + humidity_entity_id: str | None icon: str | None id: str labels: set[str] = field(default_factory=set) picture: str | None + temperature_entity_id: str | None _cache: dict[str, Any] = field(default_factory=dict, compare=False, init=False) @under_cached_property @@ -89,10 +94,12 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): "aliases": list(self.aliases), "area_id": self.id, "floor_id": self.floor_id, + "humidity_entity_id": self.humidity_entity_id, "icon": self.icon, "labels": list(self.labels), "name": self.name, "picture": self.picture, + "temperature_entity_id": self.temperature_entity_id, "created_at": self.created_at.timestamp(), "modified_at": self.modified_at.timestamp(), } @@ -138,11 +145,17 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]): area["labels"] = [] if old_minor_version < 7: - # Version 1.7 adds created_at and modiefied_at + # Version 1.7 adds created_at and modified_at created_at = utc_from_timestamp(0).isoformat() for area in old_data["areas"]: area["created_at"] = area["modified_at"] = created_at + if old_minor_version < 8: + # Version 1.8 adds humidity_entity_id and temperature_entity_id + for area in old_data["areas"]: + area["humidity_entity_id"] = None + area["temperature_entity_id"] = None + if old_major_version > 1: raise NotImplementedError return old_data # type: ignore[return-value] @@ -242,11 +255,14 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | None = None, floor_id: str | None = None, + humidity_entity_id: str | None = None, icon: str | None = None, labels: set[str] | None = None, picture: str | None = None, + temperature_entity_id: str | None = None, ) -> AreaEntry: """Create a new area.""" + self.hass.verify_event_loop_thread("area_registry.async_create") if area := self.async_get_area_by_name(name): @@ -254,14 +270,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): f"The name {name} ({area.normalized_name}) is already in use" ) + if humidity_entity_id is not None: + _validate_humidity_entity(self.hass, humidity_entity_id) + + if temperature_entity_id is not None: + _validate_temperature_entity(self.hass, temperature_entity_id) + area = AreaEntry( aliases=aliases or set(), floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, id=self._generate_id(name), labels=labels or set(), name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) area_id = area.id self.areas[area_id] = area @@ -298,20 +322,24 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( area_id, aliases=aliases, floor_id=floor_id, + humidity_entity_id=humidity_entity_id, icon=icon, labels=labels, name=name, picture=picture, + temperature_entity_id=temperature_entity_id, ) # Since updated may be the old or the new and we always fire # an event even if nothing has changed we cannot use async_fire_internal @@ -330,10 +358,12 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): *, aliases: set[str] | UndefinedType = UNDEFINED, floor_id: str | None | UndefinedType = UNDEFINED, + humidity_entity_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, + temperature_entity_id: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" old = self.areas[area_id] @@ -342,14 +372,22 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): attr_name: value for attr_name, value in ( ("aliases", aliases), + ("floor_id", floor_id), + ("humidity_entity_id", humidity_entity_id), ("icon", icon), ("labels", labels), ("picture", picture), - ("floor_id", floor_id), + ("temperature_entity_id", temperature_entity_id), ) if value is not UNDEFINED and value != getattr(old, attr_name) } + if "humidity_entity_id" in new_values and humidity_entity_id is not None: + _validate_humidity_entity(self.hass, new_values["humidity_entity_id"]) + + if "temperature_entity_id" in new_values and temperature_entity_id is not None: + _validate_temperature_entity(self.hass, new_values["temperature_entity_id"]) + if name is not UNDEFINED and name != old.name: new_values["name"] = name @@ -378,11 +416,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), floor_id=area["floor_id"], + humidity_entity_id=area["humidity_entity_id"], icon=area["icon"], id=area["id"], labels=set(area["labels"]), name=area["name"], picture=area["picture"], + temperature_entity_id=area["temperature_entity_id"], created_at=datetime.fromisoformat(area["created_at"]), modified_at=datetime.fromisoformat(area["modified_at"]), ) @@ -398,11 +438,13 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): { "aliases": list(entry.aliases), "floor_id": entry.floor_id, + "humidity_entity_id": entry.humidity_entity_id, "icon": entry.icon, "id": entry.id, "labels": list(entry.labels), "name": entry.name, "picture": entry.picture, + "temperature_entity_id": entry.temperature_entity_id, "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), } @@ -477,3 +519,33 @@ def async_entries_for_floor(registry: AreaRegistry, floor_id: str) -> list[AreaE def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaEntry]: """Return entries that match a label.""" return registry.areas.get_areas_for_label(label_id) + + +def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate temperature entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE + ): + raise ValueError(f"Entity {entity_id} is not a temperature sensor") + + +def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: + """Validate humidity entity.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.sensor import SensorDeviceClass + + if not (state := hass.states.get(entity_id)): + raise ValueError(f"Entity {entity_id} does not exist") + + if ( + state.domain != "sensor" + or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY + ): + raise ValueError(f"Entity {entity_id} is not a humidity sensor") diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py new file mode 100644 index 00000000000..4ab302749a1 --- /dev/null +++ b/homeassistant/helpers/backup.py @@ -0,0 +1,70 @@ +"""Helpers for the backup integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.components.backup import BackupManager, ManagerStateEvent + +DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") +DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") + + +@dataclass(slots=True) +class BackupData: + """Backup data stored in hass.data.""" + + backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( + default_factory=list + ) + manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) + + +@callback +def async_initialize_backup(hass: HomeAssistant) -> None: + """Initialize backup data. + + This creates the BackupData instance stored in hass.data[DATA_BACKUP] and + registers the basic backup websocket API which is used by frontend to subscribe + to backup events. + """ + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import basic_websocket + + hass.data[DATA_BACKUP] = BackupData() + basic_websocket.async_register_websocket_handlers(hass) + + +async def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_BACKUP not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + await hass.data[DATA_BACKUP].manager_ready + return hass.data[DATA_MANAGER] + + +@callback +def async_subscribe_events( + hass: HomeAssistant, + on_event: Callable[[ManagerStateEvent], None], +) -> Callable[[], None]: + """Subscribe to backup events.""" + backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions + + def remove_subscription() -> None: + backup_event_subscriptions.remove(on_event) + + backup_event_subscriptions.append(on_event) + return remove_subscription diff --git a/homeassistant/helpers/chat_session.py b/homeassistant/helpers/chat_session.py new file mode 100644 index 00000000000..e7a4ecd2ca9 --- /dev/null +++ b/homeassistant/helpers/chat_session.py @@ -0,0 +1,165 @@ +"""Helper to organize chat sessions between integrations.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from datetime import datetime, timedelta +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, +) +from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.ulid import ulid_now, ulid_to_bytes + +from .event import async_call_later + +DATA_CHAT_SESSION: HassKey[dict[str, ChatSession]] = HassKey("chat_session") +DATA_CHAT_SESSION_CLEANUP: HassKey[SessionCleanup] = HassKey("chat_session_cleanup") + +CONVERSATION_TIMEOUT = timedelta(minutes=5) +LOGGER = logging.getLogger(__name__) + +current_session: ContextVar[ChatSession | None] = ContextVar( + "current_session", default=None +) + + +@dataclass +class ChatSession: + """Represent a chat session.""" + + conversation_id: str + last_updated: datetime = field(default_factory=dt_util.utcnow) + _cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list) + + @callback + def async_updated(self) -> None: + """Update the last updated time.""" + self.last_updated = dt_util.utcnow() + + @callback + def async_on_cleanup(self, cb: CALLBACK_TYPE) -> None: + """Register a callback to clean up the session.""" + self._cleanup_callbacks.append(cb) + + @callback + def async_cleanup(self) -> None: + """Call all clean up callbacks.""" + for cb in self._cleanup_callbacks: + cb() + self._cleanup_callbacks.clear() + + +class SessionCleanup: + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the session cleanup.""" + self.hass = hass + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + CONVERSATION_TIMEOUT.total_seconds() + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, now: datetime) -> None: + """Clean up the history and schedule follow-up if necessary.""" + self.unsub = None + all_sessions = self.hass.data[DATA_CHAT_SESSION] + + # We mutate original object because current commands could be + # yielding session based on it. + for conversation_id, session in list(all_sessions.items()): + if session.last_updated + CONVERSATION_TIMEOUT < now: + LOGGER.debug("Cleaning up session %s", conversation_id) + del all_sessions[conversation_id] + session.async_cleanup() + + # Still conversations left, check again in timeout time. + if all_sessions: + self.schedule() + + +@contextmanager +def async_get_chat_session( + hass: HomeAssistant, + conversation_id: str | None = None, +) -> Generator[ChatSession]: + """Return a chat session.""" + if session := current_session.get(): + # If a session is already active and it's the requested conversation ID, + # return that. We won't update the last updated time in this case. + if session.conversation_id == conversation_id: + yield session + return + + # If it's not the same conversation ID, we will create a new session + # because it might be a conversation agent calling a tool that is talking + # to another LLM. + session = None + + all_sessions = hass.data.get(DATA_CHAT_SESSION) + if all_sessions is None: + all_sessions = {} + hass.data[DATA_CHAT_SESSION] = all_sessions + hass.data[DATA_CHAT_SESSION_CLEANUP] = SessionCleanup(hass) + + if conversation_id is None: + conversation_id = ulid_now() + + elif conversation_id in all_sessions: + session = all_sessions[conversation_id] + + else: + # Conversation IDs are ULIDs. We generate a new one if not provided. + # If an old ULID is passed in, we will generate a new one to indicate + # a new conversation was started. If the user picks their own, they + # want to track a conversation and we respect it. + try: + ulid_to_bytes(conversation_id) + conversation_id = ulid_now() + except ValueError: + pass + + if session is None: + LOGGER.debug("Creating new session %s", conversation_id) + session = ChatSession(conversation_id) + + current_session.set(session) + yield session + current_session.set(None) + + session.last_updated = dt_util.utcnow() + all_sessions[conversation_id] = session + hass.data[DATA_CHAT_SESSION_CLEANUP].schedule() diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4b5e2f277a0..0841585e1a1 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -29,7 +29,7 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType @@ -220,7 +220,7 @@ async def async_check_ha_config_file( # noqa: C901 except (vol.Invalid, HomeAssistantError) as ex: _comp_error(ex, domain, config, config[domain]) continue - except Exception as err: # noqa: BLE001 + except Exception as err: logging.getLogger(__name__).exception( "Unexpected error validating config" ) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 86d3450c3a0..aef673cb500 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass @@ -11,9 +11,8 @@ from hashlib import md5 from itertools import groupby import logging from operator import attrgetter -from typing import Any, Generic, TypedDict +from typing import Any, TypedDict -from typing_extensions import TypeVar import voluptuous as vol from voluptuous.humanize import humanize_error @@ -37,8 +36,6 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" -_EntityT = TypeVar("_EntityT", bound=Entity, default=Entity) - @dataclass(slots=True) class CollectionChange: @@ -129,7 +126,7 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection[_ItemT](ABC): +class ObservableCollection[_ItemT]: """Base collection type that can be observed.""" def __init__(self, id_manager: IDManager | None) -> None: @@ -448,7 +445,7 @@ _GROUP_BY_KEY = attrgetter("change_type") @dataclass(slots=True, frozen=True) -class _CollectionLifeCycle(Generic[_EntityT]): +class _CollectionLifeCycle[_EntityT: Entity = Entity]: """Life cycle for a collection of entities.""" domain: str @@ -523,7 +520,7 @@ class _CollectionLifeCycle(Generic[_EntityT]): @callback -def sync_entity_lifecycle( +def sync_entity_lifecycle[_EntityT: Entity = Entity]( hass: HomeAssistant, domain: str, platform: str, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5952e28a1eb..fa2dd42589b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -56,8 +56,8 @@ from homeassistant.exceptions import ( TemplateError, ) from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date @@ -884,10 +884,8 @@ def time( condition_trace_update_result(weekday=weekday, now_weekday=now_weekday) if ( - isinstance(weekday, str) - and weekday != now_weekday - or now_weekday not in weekday - ): + isinstance(weekday, str) and weekday != now_weekday + ) or now_weekday not in weekday: return False return True diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index b047e1aef81..45e2e7cf35f 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -16,11 +16,11 @@ if TYPE_CHECKING: import asyncio from homeassistant.components.bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.dhcp import DhcpServiceInfo - from homeassistant.components.ssdp import SsdpServiceInfo - from homeassistant.components.zeroconf import ZeroconfServiceInfo + from .service_info.dhcp import DhcpServiceInfo from .service_info.mqtt import MqttServiceInfo + from .service_info.ssdp import SsdpServiceInfo + from .service_info.zeroconf import ZeroconfServiceInfo type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R] @@ -67,9 +67,11 @@ class DiscoveryFlowHandler[_R: Awaitable[bool] | bool](config_entries.ConfigFlow in_progress = self._async_in_progress() if not (has_devices := bool(in_progress)): - has_devices = await cast( - "asyncio.Future[bool]", self._discovery_function(self.hass) - ) + discovery_result = self._discovery_function(self.hass) + if isinstance(discovery_result, bool): + has_devices = discovery_result + else: + has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c2a61335769..24a9de5b562 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +@callback +def async_get_redirect_uri(hass: HomeAssistant) -> str: + """Return the redirect uri.""" + if "my" in hass.config.components: + return MY_AUTH_CALLBACK_PATH + + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + + if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + return f"{ha_host}{AUTH_CALLBACK_PATH}" + + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def redirect_uri(self) -> str: """Return the redirect uri.""" - if "my" in self.hass.config.components: - return MY_AUTH_CALLBACK_PATH - - if (req := http.current_request.get()) is None: - raise RuntimeError("No current request in context") - - if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: - raise RuntimeError("No header in request") - - return f"{ha_host}{AUTH_CALLBACK_PATH}" + return async_get_redirect_uri(self.hass) @property def extra_authorize_data(self) -> dict: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3681e941eee..4978158c0f6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,8 +1,6 @@ """Helpers for config validation using voluptuous.""" -# PEP 563 seems to break typing.get_type_hints when used -# with PEP 695 syntax. Fixed in Python 3.13. -# from __future__ import annotations +from __future__ import annotations from collections.abc import Callable, Hashable, Mapping import contextlib @@ -109,8 +107,11 @@ from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES -from homeassistant.util import raise_if_invalid_path, slugify as util_slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import ( + dt as dt_util, + raise_if_invalid_path, + slugify as util_slugify, +) from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -354,7 +355,7 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return cast("list[_T]", value) if isinstance(value, list) else [value] + return cast(list[_T], value) if isinstance(value, list) else [value] def entity_id(value: Any) -> str: @@ -676,11 +677,7 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if ( - type(value) is str # noqa: E721 - or type(value) is NodeStrClass - or isinstance(value, str) - ): + if type(value) is str or type(value) is NodeStrClass or isinstance(value, str): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index adb2062a8ea..65eb2786aaf 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -3,10 +3,9 @@ from __future__ import annotations from http import HTTPStatus -from typing import Any, Generic +from typing import Any, Generic, TypeVar from aiohttp import web -from typing_extensions import TypeVar import voluptuous as vol import voluptuous_serialize @@ -18,7 +17,7 @@ from . import config_validation as cv _FlowManagerT = TypeVar( "_FlowManagerT", - bound=data_entry_flow.FlowManager[Any, Any], + bound=data_entry_flow.FlowManager[Any, Any, Any], default=data_entry_flow.FlowManager, ) @@ -71,7 +70,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Initialize a POST request. - Override `_post_impl` in subclasses which need + Override `post` and call `_post_impl` in subclasses which need to implement their own `RequestDataValidator` """ return await self._post_impl(request, data) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 83555b56dcb..c46c6806d5d 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -146,6 +146,10 @@ class Debouncer[_R_co]: """Cancel any scheduled call, and prevent new runs.""" self._shutdown_requested = True self.async_cancel() + # Release hard references to parent function + # https://github.com/home-assistant/core/issues/137237 + self._function = None + self._job = None @callback def async_cancel(self) -> None: diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 81f7821ec79..375ec58c26f 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress -from enum import Enum, EnumType, _EnumDict +from enum import EnumType, IntEnum, IntFlag, StrEnum, _EnumDict import functools import inspect import logging @@ -244,35 +244,35 @@ def _print_deprecation_warning_internal_impl( ) -class DeprecatedConstant(NamedTuple): +class DeprecatedConstant[T](NamedTuple): """Deprecated constant.""" - value: Any + value: T replacement: str breaks_in_ha_version: str | None -class DeprecatedConstantEnum(NamedTuple): +class DeprecatedConstantEnum[T: (StrEnum | IntEnum | IntFlag)](NamedTuple): """Deprecated constant.""" - enum: Enum + enum: T breaks_in_ha_version: str | None -class DeprecatedAlias(NamedTuple): +class DeprecatedAlias[T](NamedTuple): """Deprecated alias.""" - value: Any + value: T replacement: str breaks_in_ha_version: str | None -class DeferredDeprecatedAlias: +class DeferredDeprecatedAlias[T]: """Deprecated alias with deferred evaluation of the value.""" def __init__( self, - value_fn: Callable[[], Any], + value_fn: Callable[[], T], replacement: str, breaks_in_ha_version: str | None, ) -> None: @@ -282,7 +282,7 @@ class DeferredDeprecatedAlias: self._value_fn = value_fn @functools.cached_property - def value(self) -> Any: + def value(self) -> T: """Return the value.""" return self._value_fn() @@ -306,7 +306,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A replacement = deprecated_const.replacement breaks_in_ha_version = deprecated_const.breaks_in_ha_version elif isinstance(deprecated_const, DeprecatedConstantEnum): - value = deprecated_const.enum.value + value = deprecated_const.enum replacement = ( f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 981430f192d..991a6cf5a57 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from datetime import datetime from enum import StrEnum from functools import lru_cache @@ -24,11 +24,11 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue +from homeassistant.util import uuid as uuid_util from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data -import homeassistant.util.uuid as uuid_util from . import storage, translation from .debounce import Debouncer @@ -40,13 +40,13 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with attrs - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property from homeassistant.config_entries import ConfigEntry from . import entity_registry else: - from propcache import under_cached_property + from propcache.api import under_cached_property _LOGGER = logging.getLogger(__name__) @@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event ) STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 8 +STORAGE_VERSION_MINOR = 9 CLEANUP_DELAY = 10 @@ -272,6 +272,7 @@ class DeviceEntry: area_id: str | None = attr.ib(default=None) config_entries: set[str] = attr.ib(converter=set, factory=set) + config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) created_at: datetime = attr.ib(factory=utcnow) @@ -311,6 +312,10 @@ class DeviceEntry: "area_id": self.area_id, "configuration_url": self.configuration_url, "config_entries": list(self.config_entries), + "config_entries_subentries": { + config_entry_id: list(subentries) + for config_entry_id, subentries in self.config_entries_subentries.items() + }, "connections": list(self.connections), "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, @@ -354,7 +359,13 @@ class DeviceEntry: json_bytes( { "area_id": self.area_id, + # The config_entries list can be removed from the storage + # representation in HA Core 2026.2 "config_entries": list(self.config_entries), + "config_entries_subentries": { + config_entry_id: list(subentries) + for config_entry_id, subentries in self.config_entries_subentries.items() + }, "configuration_url": self.configuration_url, "connections": list(self.connections), "created_at": self.created_at, @@ -384,6 +395,7 @@ class DeletedDeviceEntry: """Deleted Device Registry Entry.""" config_entries: set[str] = attr.ib() + config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib() identifiers: set[tuple[str, str]] = attr.ib() id: str = attr.ib() @@ -395,6 +407,7 @@ class DeletedDeviceEntry: def to_device_entry( self, config_entry_id: str, + config_subentry_id: str | None, connections: set[tuple[str, str]], identifiers: set[tuple[str, str]], ) -> DeviceEntry: @@ -402,6 +415,7 @@ class DeletedDeviceEntry: return DeviceEntry( # type ignores: likely https://github.com/python/mypy/issues/8625 config_entries={config_entry_id}, # type: ignore[arg-type] + config_entries_subentries={config_entry_id: {config_subentry_id}}, connections=self.connections & connections, # type: ignore[arg-type] created_at=self.created_at, identifiers=self.identifiers & identifiers, # type: ignore[arg-type] @@ -415,7 +429,13 @@ class DeletedDeviceEntry: return json_fragment( json_bytes( { + # The config_entries list can be removed from the storage + # representation in HA Core 2026.2 "config_entries": list(self.config_entries), + "config_entries_subentries": { + config_entry_id: list(subentries) + for config_entry_id, subentries in self.config_entries_subentries.items() + }, "connections": list(self.connections), "created_at": self.created_at, "identifiers": list(self.identifiers), @@ -458,7 +478,10 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" - if old_major_version < 2: + # Support for a future major version bump to 2 added in HA Core 2025.2. + # Major versions 1 and 2 will be the same, except that version 2 will no + # longer store a list of config_entries. + if old_major_version < 3: if old_minor_version < 2: # Version 1.2 implements migration and freezes the available keys, # populate keys which were introduced before version 1.2 @@ -505,8 +528,20 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): device["created_at"] = device["modified_at"] = created_at for device in old_data["deleted_devices"]: device["created_at"] = device["modified_at"] = created_at + if old_minor_version < 9: + # Introduced in 2025.2 + for device in old_data["devices"]: + device["config_entries_subentries"] = { + config_entry_id: {None} + for config_entry_id in device["config_entries"] + } + for device in old_data["deleted_devices"]: + device["config_entries_subentries"] = { + config_entry_id: {None} + for config_entry_id in device["config_entries"] + } - if old_major_version > 1: + if old_major_version > 2: raise NotImplementedError return old_data @@ -561,6 +596,21 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( return self._connections[connection] return None + def get_entries( + self, + identifiers: set[tuple[str, str]] | None, + connections: set[tuple[str, str]] | None, + ) -> Iterable[_EntryTypeT]: + """Get entries from identifiers or connections.""" + if identifiers: + for identifier in identifiers: + if identifier in self._identifiers: + yield self._identifiers[identifier] + if connections: + for connection in _normalize_connections(connections): + if connection in self._connections: + yield self._connections[connection] + class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): """Container for active (non-deleted) device registry entries.""" @@ -667,6 +717,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Check if device is deleted.""" return self.deleted_devices.get_entry(identifiers, connections) + def _async_get_deleted_devices( + self, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, + ) -> Iterable[DeletedDeviceEntry]: + """List devices that are deleted.""" + return self.deleted_devices.get_entries(identifiers, connections) + def _substitute_name_placeholders( self, domain: str, @@ -699,6 +757,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self, *, config_entry_id: str, + config_subentry_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored @@ -789,7 +848,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: self.deleted_devices.pop(deleted_device.id) device = deleted_device.to_device_entry( - config_entry_id, connections, identifiers + config_entry_id, + # Interpret not specifying a subentry as None + config_subentry_id if config_subentry_id is not UNDEFINED else None, + connections, + identifiers, ) self.devices[device.id] = device # If creating a new device, default to the config entry name @@ -823,6 +886,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device.id, allow_collisions=True, add_config_entry_id=config_entry_id, + add_config_subentry_id=config_subentry_id, configuration_url=configuration_url, device_info_type=device_info_type, disabled_by=disabled_by, @@ -851,6 +915,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device_id: str, *, add_config_entry_id: str | UndefinedType = UNDEFINED, + add_config_subentry_id: str | None | UndefinedType = UNDEFINED, # Temporary flag so we don't blow up when collisions are implicitly introduced # by calls to async_get_or_create. Must not be set by integrations. allow_collisions: bool = False, @@ -871,25 +936,58 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, + remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry | None: - """Update device attributes.""" + """Update device attributes. + + :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + """ old = self.devices[device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs config_entries = old.config_entries + config_entries_subentries = old.config_entries_subentries if add_config_entry_id is not UNDEFINED: - if self.hass.config_entries.async_get_entry(add_config_entry_id) is None: + if ( + add_config_entry := self.hass.config_entries.async_get_entry( + add_config_entry_id + ) + ) is None: raise HomeAssistantError( f"Can't link device to unknown config entry {add_config_entry_id}" ) + if add_config_subentry_id is not UNDEFINED: + if add_config_entry_id is UNDEFINED: + raise HomeAssistantError( + "Can't add config subentry without specifying config entry" + ) + if ( + add_config_subentry_id + # mypy says add_config_entry can be None. That's impossible, because we + # raise above if that happens + and add_config_subentry_id not in add_config_entry.subentries # type: ignore[union-attr] + ): + raise HomeAssistantError( + f"Config entry {add_config_entry_id} has no subentry {add_config_subentry_id}" + ) + + if ( + remove_config_subentry_id is not UNDEFINED + and remove_config_entry_id is UNDEFINED + ): + raise HomeAssistantError( + "Can't remove config subentry without specifying config entry" + ) + if not new_connections and not new_identifiers: raise HomeAssistantError( "A device must have at least one of identifiers or connections" @@ -920,6 +1018,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): area_id = area.id if add_config_entry_id is not UNDEFINED: + if add_config_subentry_id is UNDEFINED: + # Interpret not specifying a subentry as None (the main entry) + add_config_subentry_id = None + primary_entry_id = old.primary_config_entry if ( device_info_type == "primary" @@ -939,34 +1041,61 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if add_config_entry_id not in old.config_entries: config_entries = old.config_entries | {add_config_entry_id} + config_entries_subentries = old.config_entries_subentries | { + add_config_entry_id: {add_config_subentry_id} + } + elif ( + add_config_subentry_id + not in old.config_entries_subentries[add_config_entry_id] + ): + config_entries_subentries = old.config_entries_subentries | { + add_config_entry_id: old.config_entries_subentries[ + add_config_entry_id + ] + | {add_config_subentry_id} + } if ( remove_config_entry_id is not UNDEFINED and remove_config_entry_id in config_entries ): - if config_entries == {remove_config_entry_id}: - self.async_remove_device(device_id) - return None + if remove_config_subentry_id is UNDEFINED: + config_entries_subentries = dict(old.config_entries_subentries) + del config_entries_subentries[remove_config_entry_id] + elif ( + remove_config_subentry_id + in old.config_entries_subentries[remove_config_entry_id] + ): + config_entries_subentries = old.config_entries_subentries | { + remove_config_entry_id: old.config_entries_subentries[ + remove_config_entry_id + ] + - {remove_config_subentry_id} + } + if not config_entries_subentries[remove_config_entry_id]: + del config_entries_subentries[remove_config_entry_id] - if remove_config_entry_id == old.primary_config_entry: - new_values["primary_config_entry"] = None - old_values["primary_config_entry"] = old.primary_config_entry + if remove_config_entry_id not in config_entries_subentries: + if config_entries == {remove_config_entry_id}: + self.async_remove_device(device_id) + return None - config_entries = config_entries - {remove_config_entry_id} + if remove_config_entry_id == old.primary_config_entry: + new_values["primary_config_entry"] = None + old_values["primary_config_entry"] = old.primary_config_entry + + config_entries = config_entries - {remove_config_entry_id} if config_entries != old.config_entries: new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries - for attr_name, setvalue in ( - ("connections", merge_connections), - ("identifiers", merge_identifiers), - ): - old_value = getattr(old, attr_name) - # If not undefined, check if `value` contains new items. - if setvalue is not UNDEFINED and not setvalue.issubset(old_value): - new_values[attr_name] = old_value | setvalue - old_values[attr_name] = old_value + if config_entries_subentries != old.config_entries_subentries: + new_values["config_entries_subentries"] = config_entries_subentries + old_values["config_entries_subentries"] = old.config_entries_subentries + + added_connections: set[tuple[str, str]] | None = None + added_identifiers: set[tuple[str, str]] | None = None if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( @@ -976,6 +1105,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) old_connections = old.connections if not normalized_connections.issubset(old_connections): + added_connections = normalized_connections new_values["connections"] = old_connections | normalized_connections old_values["connections"] = old_connections @@ -985,17 +1115,18 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) old_identifiers = old.identifiers if not merge_identifiers.issubset(old_identifiers): + added_identifiers = merge_identifiers new_values["identifiers"] = old_identifiers | merge_identifiers old_values["identifiers"] = old_identifiers if new_connections is not UNDEFINED: - new_values["connections"] = self._validate_connections( + added_connections = new_values["connections"] = self._validate_connections( device_id, new_connections, False ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: - new_values["identifiers"] = self._validate_identifiers( + added_identifiers = new_values["identifiers"] = self._validate_identifiers( device_id, new_identifiers, False ) old_values["identifiers"] = old.identifiers @@ -1038,6 +1169,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new = attr.evolve(old, **new_values) self.devices[device_id] = new + # NOTE: Once we solve the broader issue of duplicated devices, we might + # want to revisit it. Instead of simply removing the duplicated deleted device, + # we might want to merge the information from it into the non-deleted device. + for deleted_device in self._async_get_deleted_devices( + added_identifiers, added_connections + ): + del self.deleted_devices[deleted_device.id] + # If its only run time attributes (suggested_area) # that do not get saved we do not want to write # to disk or fire an event as we would end up @@ -1112,6 +1251,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device = self.devices.pop(device_id) self.deleted_devices[device_id] = DeletedDeviceEntry( config_entries=device.config_entries, + config_entries_subentries=device.config_entries_subentries, connections=device.connections, created_at=device.created_at, identifiers=device.identifiers, @@ -1142,7 +1282,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), + config_entries=set(device["config_entries_subentries"]), + config_entries_subentries={ + config_entry_id: set(subentries) + for config_entry_id, subentries in device[ + "config_entries_subentries" + ].items() + }, configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -1182,6 +1328,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( config_entries=set(device["config_entries"]), + config_entries_subentries={ + config_entry_id: set(subentries) + for config_entry_id, subentries in device[ + "config_entries_subentries" + ].items() + }, connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), identifiers={tuple(iden) for iden in device["identifiers"]}, @@ -1217,14 +1369,70 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if config_entries == {config_entry_id}: # Add a time stamp when the deleted device became orphaned self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, orphaned_timestamp=now_time, config_entries=set() + deleted_device, + orphaned_timestamp=now_time, + config_entries=set(), + config_entries_subentries={}, ) else: config_entries = config_entries - {config_entry_id} + config_entries_subentries = dict( + deleted_device.config_entries_subentries + ) + del config_entries_subentries[config_entry_id] # No need to reindex here since we currently # do not have a lookup by config entry self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, config_entries=config_entries + deleted_device, + config_entries=config_entries, + config_entries_subentries=config_entries_subentries, + ) + self.async_schedule_save() + + @callback + def async_clear_config_subentry( + self, config_entry_id: str, config_subentry_id: str + ) -> None: + """Clear config entry from registry entries.""" + now_time = time.time() + now_time = time.time() + for device in self.devices.get_devices_for_config_entry_id(config_entry_id): + self.async_update_device( + device.id, + remove_config_entry_id=config_entry_id, + remove_config_subentry_id=config_subentry_id, + ) + for deleted_device in list(self.deleted_devices.values()): + config_entries = deleted_device.config_entries + config_entries_subentries = deleted_device.config_entries_subentries + if ( + config_entry_id not in config_entries_subentries + or config_subentry_id not in config_entries_subentries[config_entry_id] + ): + continue + if config_entries_subentries == {config_entry_id: {config_subentry_id}}: + # We're removing the last config subentry from the last config + # entry, add a time stamp when the deleted device became orphaned + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + orphaned_timestamp=now_time, + config_entries=set(), + config_entries_subentries={}, + ) + else: + config_entries_subentries = config_entries_subentries | { + config_entry_id: config_entries_subentries[config_entry_id] + - {config_subentry_id} + } + if not config_entries_subentries[config_entry_id]: + del config_entries_subentries[config_entry_id] + config_entries = config_entries - {config_entry_id} + # No need to reindex here since we currently + # do not have a lookup by config entry + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + config_entries=config_entries, + config_entries_subentries=config_entries_subentries, ) self.async_schedule_save() diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index a5a790b7ce5..350ae6dbd6a 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -154,7 +154,7 @@ def _format_err[*_Ts]( return ( # Functions wrapped in partial do not have a __name__ - f"Exception in {getattr(target, "__name__", None) or target} " + f"Exception in {getattr(target, '__name__', None) or target} " f"when dispatching '{signal}': {args}" ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 19076c4edc0..bed5ce586c5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -18,7 +18,7 @@ import time from types import FunctionType from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict, final -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.const import ( @@ -1028,7 +1028,7 @@ class Entity( return STATE_UNAVAILABLE if (state := self.state) is None: return STATE_UNKNOWN - if type(state) is str: # noqa: E721 + if type(state) is str: # fast path for strings return state if isinstance(state, float): @@ -1085,9 +1085,9 @@ class Entity( state = self._stringify_state(available) if available: if state_attributes := self.state_attributes: - attr.update(state_attributes) + attr |= state_attributes if extra_state_attributes := self.extra_state_attributes: - attr.update(extra_state_attributes) + attr |= extra_state_attributes if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement @@ -1214,7 +1214,7 @@ class Entity( else: # Overwrite properties that have been set in the config file. if custom := customize.get(entity_id): - attr.update(custom) + attr |= custom if ( self._context_set is not None @@ -1480,9 +1480,9 @@ class Entity( if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests - assert ( - not self.registry_entry.disabled_by - ), f"Entity '{self.entity_id}' is being added while it's disabled" + assert not self.registry_entry.disabled_by, ( + f"Entity '{self.entity_id}' is being added while it's disabled" + ) self.async_on_remove( async_track_entity_registry_updated_event( diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1be7289401c..02508e9ee9e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -7,9 +7,7 @@ from collections.abc import Callable, Iterable from datetime import timedelta import logging from types import ModuleType -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry @@ -39,8 +37,6 @@ from .typing import ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES = "entity_components" -_EntityT = TypeVar("_EntityT", bound=entity.Entity, default=entity.Entity) - @bind_hass async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: @@ -64,7 +60,7 @@ async def async_update_entity(hass: HomeAssistant, entity_id: str) -> None: await entity_obj.async_update_ha_state(True) -class EntityComponent(Generic[_EntityT]): +class EntityComponent[_EntityT: entity.Entity = entity.Entity]: """The EntityComponent manages platforms that manage entities. An example of an entity component is 'light', which manages platforms such diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0d7614c569c..11a9786f86e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -80,6 +80,22 @@ class AddEntitiesCallback(Protocol): """Define add_entities type.""" +class AddConfigEntryEntitiesCallback(Protocol): + """Protocol type for EntityPlatform.add_entities callback.""" + + def __call__( + self, + new_entities: Iterable[Entity], + update_before_add: bool = False, + *, + config_subentry_id: str | None = None, + ) -> None: + """Define add_entities type. + + :param config_subentry_id: subentry which the entities should be added to + """ + + class EntityPlatformModule(Protocol): """Protocol type for entity platform modules.""" @@ -105,7 +121,7 @@ class EntityPlatformModule(Protocol): self, hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an integration platform from a config entry.""" @@ -426,11 +442,12 @@ class EntityPlatform: type(exc).__name__, ) return False - except Exception: + except Exception as exc: logger.exception( - "Error while setting up %s platform for %s", + "Error while setting up %s platform for %s: %s", self.platform_name, self.domain, + exc, # noqa: TRY401 ) return False else: @@ -516,13 +533,21 @@ class EntityPlatform: @callback def _async_schedule_add_entities_for_entry( - self, new_entities: Iterable[Entity], update_before_add: bool = False + self, + new_entities: Iterable[Entity], + update_before_add: bool = False, + *, + config_subentry_id: str | None = None, ) -> None: """Schedule adding entities for a single platform async and track the task.""" assert self.config_entry task = self.config_entry.async_create_task( self.hass, - self.async_add_entities(new_entities, update_before_add=update_before_add), + self.async_add_entities( + new_entities, + update_before_add=update_before_add, + config_subentry_id=config_subentry_id, + ), f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}", eager_start=True, ) @@ -624,12 +649,27 @@ class EntityPlatform: ) async def async_add_entities( - self, new_entities: Iterable[Entity], update_before_add: bool = False + self, + new_entities: Iterable[Entity], + update_before_add: bool = False, + *, + config_subentry_id: str | None = None, ) -> None: """Add entities for a single platform async. This method must be run in the event loop. + + :param config_subentry_id: subentry which the entities should be added to """ + if config_subentry_id and ( + not self.config_entry + or config_subentry_id not in self.config_entry.subentries + ): + raise HomeAssistantError( + f"Can't add entities to unknown subentry {config_subentry_id} of config " + f"entry {self.config_entry.entry_id if self.config_entry else None}" + ) + # handle empty list from component/platform if not new_entities: # type: ignore[truthy-iterable] return @@ -640,7 +680,9 @@ class EntityPlatform: entities: list[Entity] = [] for entity in new_entities: coros.append( - self._async_add_entity(entity, update_before_add, entity_registry) + self._async_add_entity( + entity, update_before_add, entity_registry, config_subentry_id + ) ) entities.append(entity) @@ -719,6 +761,7 @@ class EntityPlatform: entity: Entity, update_before_add: bool, entity_registry: EntityRegistry, + config_subentry_id: str | None, ) -> None: """Add an entity to the platform.""" if entity is None: @@ -778,6 +821,7 @@ class EntityPlatform: try: device = dev_reg.async_get(self.hass).async_get_or_create( config_entry_id=self.config_entry.entry_id, + config_subentry_id=config_subentry_id, **device_info, ) except dev_reg.DeviceInfoError as exc: @@ -824,6 +868,7 @@ class EntityPlatform: entity.unique_id, capabilities=entity.capability_attributes, config_entry=self.config_entry, + config_subentry_id=config_subentry_id, device_id=device.id if device else None, disabled_by=disabled_by, entity_category=entity.entity_category, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 6b6becd4dd3..684d00fe344 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,11 +65,11 @@ from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: # mypy cannot workout _cache Protocol with attrs - from propcache import cached_property as under_cached_property + from propcache.api import cached_property as under_cached_property from homeassistant.config_entries import ConfigEntry else: - from propcache import under_cached_property + from propcache.api import under_cached_property DATA_REGISTRY: HassKey[EntityRegistry] = HassKey("entity_registry") EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = EventType( @@ -79,16 +79,14 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 15 +STORAGE_VERSION_MINOR = 16 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 ORPHANED_ENTITY_KEEP_SECONDS = 3600 * 24 * 30 ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { - # mypy does not understand strenum - val: idx # type: ignore[misc] - for idx, val in enumerate(EntityCategory) + val: idx for idx, val in enumerate(EntityCategory) } ENTITY_CATEGORY_INDEX_TO_VALUE = dict(enumerate(EntityCategory)) @@ -179,6 +177,7 @@ class RegistryEntry: categories: dict[str, str] = attr.ib(factory=dict) capabilities: Mapping[str, Any] | None = attr.ib(default=None) config_entry_id: str | None = attr.ib(default=None) + config_subentry_id: str | None = attr.ib(default=None) created_at: datetime = attr.ib(factory=utcnow) device_class: str | None = attr.ib(default=None) device_id: str | None = attr.ib(default=None) @@ -282,6 +281,7 @@ class RegistryEntry: "area_id": self.area_id, "categories": self.categories, "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "created_at": self.created_at.timestamp(), "device_id": self.device_id, "disabled_by": self.disabled_by, @@ -343,6 +343,7 @@ class RegistryEntry: "categories": self.categories, "capabilities": self.capabilities, "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "created_at": self.created_at, "device_class": self.device_class, "device_id": self.device_id, @@ -407,6 +408,7 @@ class DeletedRegistryEntry: unique_id: str = attr.ib() platform: str = attr.ib() config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() domain: str = attr.ib(init=False, repr=False) id: str = attr.ib() orphaned_timestamp: float | None = attr.ib() @@ -426,6 +428,7 @@ class DeletedRegistryEntry: json_bytes( { "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "created_at": self.created_at, "entity_id": self.entity_id, "id": self.id, @@ -541,6 +544,13 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entity in data["deleted_entities"]: entity["created_at"] = entity["modified_at"] = created_at + if old_minor_version < 16: + # Version 1.16 adds config_subentry_id + for entity in data["entities"]: + entity["config_subentry_id"] = None + for entity in data["deleted_entities"]: + entity["config_subentry_id"] = None + if old_major_version > 1: raise NotImplementedError return data @@ -648,10 +658,13 @@ def _validate_item( domain: str, platform: str, *, + config_entry_id: str | None | UndefinedType = None, + config_subentry_id: str | None | UndefinedType = None, device_id: str | None | UndefinedType = None, disabled_by: RegistryEntryDisabler | None | UndefinedType = None, entity_category: EntityCategory | None | UndefinedType = None, hidden_by: RegistryEntryHider | None | UndefinedType = None, + old_config_subentry_id: str | None = None, report_non_string_unique_id: bool = True, unique_id: str | Hashable | UndefinedType | Any, ) -> None: @@ -666,12 +679,37 @@ def _validate_item( # In HA Core 2025.10, we should fail if unique_id is not a string report_issue = async_suggest_report_issue(hass, integration_domain=platform) _LOGGER.error( - ("'%s' from integration %s has a non string unique_id" " '%s', please %s"), + "'%s' from integration %s has a non string unique_id '%s', please %s", domain, platform, unique_id, report_issue, ) + if config_entry_id and config_entry_id is not UNDEFINED: + if not hass.config_entries.async_get_entry(config_entry_id): + raise ValueError( + f"Can't link entity to unknown config entry {config_entry_id}" + ) + if ( + config_entry_id + and config_entry_id is not UNDEFINED + and old_config_subentry_id + and config_subentry_id is UNDEFINED + ): + raise ValueError("Can't change config entry without changing subentry") + if ( + config_entry_id + and config_entry_id is not UNDEFINED + and config_subentry_id + and config_subentry_id is not UNDEFINED + ): + if ( + not (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + or config_subentry_id not in config_entry.subentries + ): + raise ValueError( + f"Config entry {config_entry_id} has no subentry {config_subentry_id}" + ) if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) if not device_registry.async_get(device_id): @@ -799,7 +837,7 @@ class EntityRegistry(BaseRegistry): tries += 1 len_suffix = len(str(tries)) + 1 test_string = ( - f"{preferred_string[:MAX_LENGTH_STATE_ENTITY_ID-len_suffix]}_{tries}" + f"{preferred_string[: MAX_LENGTH_STATE_ENTITY_ID - len_suffix]}_{tries}" ) return test_string @@ -822,6 +860,7 @@ class EntityRegistry(BaseRegistry): # Data that we want entry to have capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry: ConfigEntry | None | UndefinedType = UNDEFINED, + config_subentry_id: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, entity_category: EntityCategory | UndefinedType | None = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, @@ -848,6 +887,7 @@ class EntityRegistry(BaseRegistry): entity_id, capabilities=capabilities, config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_id=device_id, entity_category=entity_category, has_entity_name=has_entity_name, @@ -864,6 +904,8 @@ class EntityRegistry(BaseRegistry): self.hass, domain, platform, + config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, @@ -902,6 +944,7 @@ class EntityRegistry(BaseRegistry): entry = RegistryEntry( capabilities=none_if_undefined(capabilities), config_entry_id=none_if_undefined(config_entry_id), + config_subentry_id=none_if_undefined(config_subentry_id), created_at=created_at, device_id=none_if_undefined(device_id), disabled_by=disabled_by, @@ -944,6 +987,7 @@ class EntityRegistry(BaseRegistry): orphaned_timestamp = None if config_entry_id else time.time() self.deleted_entities[key] = DeletedRegistryEntry( config_entry_id=config_entry_id, + config_subentry_id=entity.config_subentry_id, created_at=entity.created_at, entity_id=entity_id, id=entity.id, @@ -1003,6 +1047,20 @@ class EntityRegistry(BaseRegistry): ): self.async_remove(entity.entity_id) + # Remove entities which belong to config subentries no longer associated with the + # device + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + if ( + (config_entry_id := entity.config_entry_id) is not None + and config_entry_id in device.config_entries + and entity.config_subentry_id + not in device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) + # Re-enable disabled entities if the device is no longer disabled if not device.disabled: entities = async_entries_for_device( @@ -1036,6 +1094,7 @@ class EntityRegistry(BaseRegistry): categories: dict[str, str] | UndefinedType = UNDEFINED, capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry_id: str | None | UndefinedType = UNDEFINED, + config_subentry_id: str | None | UndefinedType = UNDEFINED, device_class: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, @@ -1068,6 +1127,7 @@ class EntityRegistry(BaseRegistry): ("categories", categories), ("capabilities", capabilities), ("config_entry_id", config_entry_id), + ("config_subentry_id", config_subentry_id), ("device_class", device_class), ("device_id", device_id), ("disabled_by", disabled_by), @@ -1096,10 +1156,13 @@ class EntityRegistry(BaseRegistry): self.hass, old.domain, old.platform, + config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, hidden_by=hidden_by, + old_config_subentry_id=old.config_subentry_id, unique_id=new_unique_id, ) @@ -1164,6 +1227,7 @@ class EntityRegistry(BaseRegistry): categories: dict[str, str] | UndefinedType = UNDEFINED, capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry_id: str | None | UndefinedType = UNDEFINED, + config_subentry_id: str | None | UndefinedType = UNDEFINED, device_class: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, @@ -1190,6 +1254,7 @@ class EntityRegistry(BaseRegistry): categories=categories, capabilities=capabilities, config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_class=device_class, device_id=device_id, disabled_by=disabled_by, @@ -1216,6 +1281,7 @@ class EntityRegistry(BaseRegistry): new_platform: str, *, new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, new_device_id: str | None | UndefinedType = UNDEFINED, ) -> RegistryEntry: @@ -1240,6 +1306,7 @@ class EntityRegistry(BaseRegistry): entity_id, new_unique_id=new_unique_id, config_entry_id=new_config_entry_id, + config_subentry_id=new_config_subentry_id, device_id=new_device_id, platform=new_platform, ) @@ -1302,6 +1369,7 @@ class EntityRegistry(BaseRegistry): categories=entity["categories"], capabilities=entity["capabilities"], config_entry_id=entity["config_entry_id"], + config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], device_id=entity["device_id"], @@ -1351,6 +1419,7 @@ class EntityRegistry(BaseRegistry): ) deleted_entities[key] = DeletedRegistryEntry( config_entry_id=entity["config_entry_id"], + config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), entity_id=entity["entity_id"], id=entity["id"], @@ -1409,6 +1478,30 @@ class EntityRegistry(BaseRegistry): ) self.async_schedule_save() + @callback + def async_clear_config_subentry( + self, config_entry_id: str, config_subentry_id: str + ) -> None: + """Clear config subentry from registry entries.""" + now_time = time.time() + for entity_id in [ + entry.entity_id + for entry in self.entities.get_entries_for_config_entry_id(config_entry_id) + if entry.config_subentry_id == config_subentry_id + ]: + self.async_remove(entity_id) + for key, deleted_entity in list(self.deleted_entities.items()): + if config_subentry_id != deleted_entity.config_subentry_id: + continue + # Add a time stamp when the deleted entity became orphaned + self.deleted_entities[key] = attr.evolve( + deleted_entity, + orphaned_timestamp=now_time, + config_entry_id=None, + config_subentry_id=None, + ) + self.async_schedule_save() + @callback def async_purge_expired_orphaned_entities(self) -> None: """Purge expired orphaned entities from the registry. diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 72a4ef3c050..b363bc21e86 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -951,8 +951,7 @@ def async_track_template( if ( not isinstance(last_result, TemplateError) and result_as_boolean(last_result) - or not result_as_boolean(result) - ): + ) or not result_as_boolean(result): return hass.async_run_hass_job( diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 6d03ae4ffd2..f33f8407e47 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -13,7 +13,7 @@ import sys from types import FrameType from typing import Any, cast -from propcache import cached_property +from propcache.api import cached_property from homeassistant.core import HomeAssistant, async_get_hass_or_none from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 22f8e2acbeb..68daf5c7939 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -46,9 +46,9 @@ def request_handler_factory( ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" is_coroutinefunction = asyncio.iscoroutinefunction(handler) - assert is_coroutinefunction or is_callback( - handler - ), "Handler should be a coroutine or a callback." + assert is_coroutinefunction or is_callback(handler), ( + "Handler should be a coroutine or a callback." + ) async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index ce8205eb915..a8c1b0b2186 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -78,7 +78,7 @@ async def _async_get_component_icons( class _IconsCache: """Cache for icons.""" - __slots__ = ("_hass", "_loaded", "_cache", "_lock") + __slots__ = ("_cache", "_hass", "_loaded", "_lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 468539f5a9d..0bb96615d3f 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -12,7 +12,7 @@ from itertools import groupby import logging from typing import Any -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant.components.homeassistant.exposed_entities import async_should_expose @@ -58,6 +58,8 @@ INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" +INTENT_BROADCAST = "HassBroadcast" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -214,6 +216,9 @@ class MatchFailedReason(Enum): DUPLICATE_NAME = auto() """Two or more entities matched the same name constraint and could not be disambiguated.""" + MULTIPLE_TARGETS = auto() + """Two or more entities matched when a single target is required.""" + def is_no_entities_reason(self) -> bool: """Return True if the match failed because no entities matched.""" return self not in ( @@ -254,6 +259,9 @@ class MatchTargetsConstraints: allow_duplicate_names: bool = False """True if entities with duplicate names are allowed in result.""" + single_target: bool = False + """True if result must contain a single target.""" + @property def has_constraints(self) -> bool: """Returns True if at least one constraint is set (ignores assistant).""" @@ -265,6 +273,7 @@ class MatchTargetsConstraints: or self.device_classes or self.features or self.states + or self.single_target ) @@ -290,7 +299,7 @@ class MatchTargetsResult: """Reason for failed match when is_match = False.""" states: list[State] = field(default_factory=list) - """List of matched entity states when is_match = True.""" + """List of matched entity states.""" no_match_name: str | None = None """Name of invalid area/floor or duplicate name when match fails for those reasons.""" @@ -356,7 +365,6 @@ class MatchTargetsCandidate: is_exposed: bool entity: entity_registry.RegistryEntry | None = None area: area_registry.AreaEntry | None = None - floor: floor_registry.FloorEntry | None = None device: device_registry.DeviceEntry | None = None matched_name: str | None = None @@ -499,12 +507,22 @@ def _add_areas( candidate.area = areas.async_get_area(candidate.device.area_id) +def _default_area_candidate_filter( + candidate: MatchTargetsCandidate, possible_area_ids: Collection[str] +) -> bool: + """Keep candidates in the possible areas.""" + return (candidate.area is not None) and (candidate.area.id in possible_area_ids) + + @callback def async_match_targets( # noqa: C901 hass: HomeAssistant, constraints: MatchTargetsConstraints, preferences: MatchTargetsPreferences | None = None, states: list[State] | None = None, + area_candidate_filter: Callable[ + [MatchTargetsCandidate, Collection[str]], bool + ] = _default_area_candidate_filter, ) -> MatchTargetsResult: """Match entities based on constraints in order to handle an intent.""" preferences = preferences or MatchTargetsPreferences() @@ -548,6 +566,7 @@ def async_match_targets( # noqa: C901 or constraints.device_classes or constraints.area_name or constraints.floor_name + or constraints.single_target ): if constraints.assistant: # Check exposure @@ -614,9 +633,7 @@ def async_match_targets( # noqa: C901 } candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -640,9 +657,7 @@ def async_match_targets( # noqa: C901 # May be constrained by floors above possible_area_ids.intersection_update(matching_area_ids) candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -692,7 +707,7 @@ def async_match_targets( # noqa: C901 group_candidates = [ c for c in group_candidates - if (c.area is not None) and (c.area.id == preferences.area_id) + if area_candidate_filter(c, {preferences.area_id}) ] if len(group_candidates) < 2: # Disambiguated by area @@ -718,6 +733,48 @@ def async_match_targets( # noqa: C901 candidates = final_candidates + if constraints.single_target and len(candidates) > 1: + # Find best match using preferences + if not (preferences.area_id or preferences.floor_id): + # No preferences + return MatchTargetsResult( + False, + MatchFailedReason.MULTIPLE_TARGETS, + states=[c.state for c in candidates], + ) + + if not areas_added: + ar = area_registry.async_get(hass) + dr = device_registry.async_get(hass) + _add_areas(ar, dr, candidates) + areas_added = True + + filtered_candidates: list[MatchTargetsCandidate] = candidates + if preferences.area_id: + # Filter by area + filtered_candidates = [ + c for c in candidates if area_candidate_filter(c, {preferences.area_id}) + ] + + if (len(filtered_candidates) > 1) and preferences.floor_id: + # Filter by floor + filtered_candidates = [ + c + for c in candidates + if c.area and (c.area.floor_id == preferences.floor_id) + ] + + if len(filtered_candidates) != 1: + # Filtering could not restrict to a single target + return MatchTargetsResult( + False, + MatchFailedReason.MULTIPLE_TARGETS, + states=[c.state for c in candidates], + ) + + # Filtering succeeded + candidates = filtered_candidates + return MatchTargetsResult( True, None, @@ -1201,17 +1258,17 @@ class Intent: """Hold the intent.""" __slots__ = [ + "assistant", + "category", + "context", + "conversation_agent_id", + "device_id", "hass", - "platform", "intent_type", + "language", + "platform", "slots", "text_input", - "context", - "language", - "category", - "assistant", - "device_id", - "conversation_agent_id", ] def __init__( diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 109d363d262..1a1373e19ef 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -12,8 +12,8 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index ebb74856429..a97dd48bf61 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( # noqa: F401 +from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, SerializationError, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 38d80d5649d..4ad2bdd6563 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -4,20 +4,20 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field as dc_field +from datetime import timedelta from decimal import Decimal from enum import Enum from functools import cache, partial -from typing import Any +from typing import Any, cast import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert -from homeassistant.components.climate import INTENT_GET_TEMPERATURE -from homeassistant.components.conversation import ( - ConversationTraceEventType, - async_conversation_trace_append, +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, ) from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose @@ -32,9 +32,10 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType +from homeassistant.util.ulid import ulid_now from . import ( area_registry as ar, @@ -48,9 +49,9 @@ from . import ( ) from .singleton import singleton -SCRIPT_PARAMETERS_CACHE: HassKey[dict[str, tuple[str | None, vol.Schema]]] = HassKey( - "llm_script_parameters_cache" -) +ACTION_PARAMETERS_CACHE: HassKey[ + dict[str, dict[str, tuple[str | None, vol.Schema]]] +] = HassKey("llm_action_parameters_cache") LLM_API_ASSIST = "assist" @@ -85,7 +86,7 @@ def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: @callback -def async_register_api(hass: HomeAssistant, api: API) -> None: +def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: """Register an API to be exposed to LLMs.""" apis = _async_get_apis(hass) @@ -94,6 +95,13 @@ def async_register_api(hass: HomeAssistant, api: API) -> None: apis[api.id] = api + @callback + def unregister() -> None: + """Unregister the API.""" + apis.pop(api.id) + + return unregister + async def async_get_api( hass: HomeAssistant, api_id: str, llm_context: LLMContext @@ -131,6 +139,8 @@ class ToolInput: tool_name: str tool_args: dict[str, Any] + # Using lambda for default to allow patching in tests + id: str = dc_field(default_factory=lambda: ulid_now()) # pylint: disable=unnecessary-lambda class Tool: @@ -164,6 +174,12 @@ class APIInstance: async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.conversation import ( + ConversationTraceEventType, + async_conversation_trace_append, + ) + async_conversation_trace_append( ConversationTraceEventType.TOOL_CALL, {"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args}, @@ -268,7 +284,7 @@ class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - INTENT_GET_TEMPERATURE, + intent.INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, INTENT_OPEN_COVER, # deprecated INTENT_CLOSE_COVER, # deprecated @@ -312,12 +328,21 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - """Return the prompt for the API.""" - if not exposed_entities: + if not exposed_entities or not exposed_entities["entities"]: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." ) + return "\n".join( + [ + *self._async_get_preable(llm_context), + *self._async_get_exposed_entities_prompt(llm_context, exposed_entities), + ] + ) + + @callback + def _async_get_preable(self, llm_context: LLMContext) -> list[str]: + """Return the prompt for the API.""" prompt = [ ( @@ -357,13 +382,22 @@ class AssistAPI(API): ): prompt.append("This device is not able to start timers.") - if exposed_entities: + return prompt + + @callback + def _async_get_exposed_entities_prompt( + self, llm_context: LLMContext, exposed_entities: dict | None + ) -> list[str]: + """Return the prompt for the API for exposed entities.""" + prompt = [] + + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) - return "\n".join(prompt) + return prompt @callback def _async_get_tools( @@ -393,8 +427,9 @@ class AssistAPI(API): exposed_domains: set[str] | None = None if exposed_entities is not None: exposed_domains = { - split_entity_id(entity_id)[0] for entity_id in exposed_entities + info["domain"] for info in exposed_entities["entities"].values() } + intent_handlers = [ intent_handler for intent_handler in intent_handlers @@ -407,22 +442,28 @@ class AssistAPI(API): for intent_handler in intent_handlers ] - if llm_context.assistant is not None: - for state in self.hass.states.async_all(SCRIPT_DOMAIN): - if not async_should_expose( - self.hass, llm_context.assistant, state.entity_id - ): - continue + if exposed_entities: + if exposed_entities[CALENDAR_DOMAIN]: + names = [] + for info in exposed_entities[CALENDAR_DOMAIN].values(): + names.extend(info["names"].split(", ")) + tools.append(CalendarGetEventsTool(names)) - tools.append(ScriptTool(self.hass, state.entity_id)) + tools.extend( + ScriptTool(self.hass, script_entity_id) + for script_entity_id in exposed_entities[SCRIPT_DOMAIN] + ) return tools def _get_exposed_entities( hass: HomeAssistant, assistant: str -) -> dict[str, dict[str, Any]]: - """Get exposed entities.""" +) -> dict[str, dict[str, dict[str, Any]]]: + """Get exposed entities. + + Splits out calendars and scripts. + """ area_registry = ar.async_get(hass) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -443,12 +484,13 @@ def _get_exposed_entities( } entities = {} + data: dict[str, dict[str, Any]] = { + SCRIPT_DOMAIN: {}, + CALENDAR_DOMAIN: {}, + } for state in hass.states.async_all(): - if ( - not async_should_expose(hass, assistant, state.entity_id) - or state.domain == SCRIPT_DOMAIN - ): + if not async_should_expose(hass, assistant, state.entity_id): continue description: str | None = None @@ -487,17 +529,23 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: info["attributes"] = attributes - entities[state.entity_id] = info + if state.domain in data: + data[state.domain][state.entity_id] = info + else: + entities[state.entity_id] = info - return entities + data["entities"] = entities + return data def _selector_serializer(schema: Any) -> Any: # noqa: C901 @@ -608,104 +656,105 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return {"type": "string"} -def _get_cached_script_parameters( - hass: HomeAssistant, entity_id: str +def _get_cached_action_parameters( + hass: HomeAssistant, domain: str, action: str ) -> tuple[str | None, vol.Schema]: - """Get script description and schema.""" - entity_registry = er.async_get(hass) - + """Get action description and schema.""" description = None parameters = vol.Schema({}) - entity_entry = entity_registry.async_get(entity_id) - if entity_entry and entity_entry.unique_id: - parameters_cache = hass.data.get(SCRIPT_PARAMETERS_CACHE) - if parameters_cache is None: - parameters_cache = hass.data[SCRIPT_PARAMETERS_CACHE] = {} + parameters_cache = hass.data.get(ACTION_PARAMETERS_CACHE) - @callback - def clear_cache(event: Event) -> None: - """Clear script parameter cache on script reload or delete.""" - if ( - event.data[ATTR_DOMAIN] == SCRIPT_DOMAIN - and event.data[ATTR_SERVICE] in parameters_cache - ): - parameters_cache.pop(event.data[ATTR_SERVICE]) + if parameters_cache is None: + parameters_cache = hass.data[ACTION_PARAMETERS_CACHE] = {} - cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache) + @callback + def clear_cache(event: Event) -> None: + """Clear action parameter cache on action removal.""" + if ( + event.data[ATTR_DOMAIN] in parameters_cache + and event.data[ATTR_SERVICE] + in parameters_cache[event.data[ATTR_DOMAIN]] + ): + parameters_cache[event.data[ATTR_DOMAIN]].pop(event.data[ATTR_SERVICE]) - @callback - def on_homeassistant_close(event: Event) -> None: - """Cleanup.""" - cancel() + cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close - ) + @callback + def on_homeassistant_close(event: Event) -> None: + """Cleanup.""" + cancel() - if entity_entry.unique_id in parameters_cache: - return parameters_cache[entity_entry.unique_id] + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close) - if service_desc := service.async_get_cached_service_description( - hass, SCRIPT_DOMAIN, entity_entry.unique_id - ): - description = service_desc.get("description") - schema: dict[vol.Marker, Any] = {} - fields = service_desc.get("fields", {}) + if domain in parameters_cache and action in parameters_cache[domain]: + return parameters_cache[domain][action] - for field, config in fields.items(): - field_description = config.get("description") - if not field_description: - field_description = config.get("name") - key: vol.Marker - if config.get("required"): - key = vol.Required(field, description=field_description) - else: - key = vol.Optional(field, description=field_description) - if "selector" in config: - schema[key] = selector.selector(config["selector"]) - else: - schema[key] = cv.string + if action_desc := service.async_get_cached_service_description( + hass, domain, action + ): + description = action_desc.get("description") + schema: dict[vol.Marker, Any] = {} + fields = action_desc.get("fields", {}) - parameters = vol.Schema(schema) + for field, config in fields.items(): + field_description = config.get("description") + if not field_description: + field_description = config.get("name") + key: vol.Marker + if config.get("required"): + key = vol.Required(field, description=field_description) + else: + key = vol.Optional(field, description=field_description) + if "selector" in config: + schema[key] = selector.selector(config["selector"]) + else: + schema[key] = cv.string - aliases: list[str] = [] - if entity_entry.name: - aliases.append(entity_entry.name) - if entity_entry.aliases: - aliases.extend(entity_entry.aliases) - if aliases: - if description: - description = description + ". Aliases: " + str(list(aliases)) - else: - description = "Aliases: " + str(list(aliases)) + parameters = vol.Schema(schema) - parameters_cache[entity_entry.unique_id] = (description, parameters) + if domain == SCRIPT_DOMAIN: + entity_registry = er.async_get(hass) + if ( + entity_id := entity_registry.async_get_entity_id(domain, domain, action) + ) and (entity_entry := entity_registry.async_get(entity_id)): + aliases: list[str] = [] + if entity_entry.name: + aliases.append(entity_entry.name) + if entity_entry.aliases: + aliases.extend(entity_entry.aliases) + if aliases: + if description: + description = description + ". Aliases: " + str(list(aliases)) + else: + description = "Aliases: " + str(list(aliases)) + + parameters_cache.setdefault(domain, {})[action] = (description, parameters) return description, parameters -class ScriptTool(Tool): - """LLM Tool representing a Script.""" +class ActionTool(Tool): + """LLM Tool representing an action.""" def __init__( self, hass: HomeAssistant, - script_entity_id: str, + domain: str, + action: str, ) -> None: """Init the class.""" - self._object_id = self.name = split_entity_id(script_entity_id)[1] - if self.name[0].isdigit(): - self.name = "_" + self.name - - self.description, self.parameters = _get_cached_script_parameters( - hass, script_entity_id + self._domain = domain + self._action = action + self.name = f"{domain}.{action}" + self.description, self.parameters = _get_cached_action_parameters( + hass, domain, action ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: - """Run the script.""" + """Call the action.""" for field, validator in self.parameters.schema.items(): if field not in tool_input.tool_args: @@ -737,8 +786,8 @@ class ScriptTool(Tool): tool_input.tool_args[field] = floor result = await hass.services.async_call( - SCRIPT_DOMAIN, - self._object_id, + self._domain, + self._action, tool_input.tool_args, context=llm_context.context, blocking=True, @@ -746,3 +795,93 @@ class ScriptTool(Tool): ) return {"success": True, "result": result} + + +class ScriptTool(ActionTool): + """LLM Tool representing a Script.""" + + def __init__( + self, + hass: HomeAssistant, + script_entity_id: str, + ) -> None: + """Init the class.""" + script_name = split_entity_id(script_entity_id)[1] + + action = script_name + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(script_entity_id) + if entity_entry and entity_entry.unique_id: + action = entity_entry.unique_id + + super().__init__(hass, SCRIPT_DOMAIN, action) + + self.name = script_name + if self.name[0].isdigit(): + self.name = "_" + self.name + + +class CalendarGetEventsTool(Tool): + """LLM Tool allowing querying a calendar.""" + + name = "calendar_get_events" + description = ( + "Get events from a calendar. " + "When asked if something happens, search the whole week. " + "Results are RFC 5545 which means 'end' is exclusive." + ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a calendar.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["calendar"], + domains=[CALENDAR_DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "Calendar not found"} + + entity_id = result.states[0].entity_id + if data["range"] == "today": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=1) + elif data["range"] == "week": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=7) + + service_data = { + "entity_id": entity_id, + "start_date_time": start.isoformat(), + "end_date_time": end.isoformat(), + } + + service_result = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + + events = [ + event if "T" in event["start"] else {**event, "all_day": True} + for event in cast(dict, service_result)[entity_id]["events"] + ] + + return {"success": True, "result": events} diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index a12de4f9029..5264869d037 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -7,7 +7,7 @@ import logging from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, State -from homeassistant.util import location as loc_util +from homeassistant.util import location as location_util _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State return min( with_location, - key=lambda state: loc_util.distance( + key=lambda state: location_util.distance( state.attributes.get(ATTR_LATITUDE), state.attributes.get(ATTR_LONGITUDE), latitude, diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 59604944eeb..7ad319419c1 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DOMAIN: HassKey[RecorderData] = HassKey("recorder") +DATA_RECORDER: HassKey[RecorderData] = HassKey("recorder") DATA_INSTANCE: HassKey[Recorder] = HassKey("recorder_instance") @@ -52,24 +52,19 @@ def async_migration_is_live(hass: HomeAssistant) -> bool: @callback def async_initialize_recorder(hass: HomeAssistant) -> None: - """Initialize recorder data.""" + """Initialize recorder data. + + This creates the RecorderData instance stored in hass.data[DATA_RECORDER] and + registers the basic recorder websocket API which is used by frontend to determine + if the recorder is migrating the database. + """ # pylint: disable-next=import-outside-toplevel from homeassistant.components.recorder.basic_websocket_api import async_setup - hass.data[DOMAIN] = RecorderData() + hass.data[DATA_RECORDER] = RecorderData() async_setup(hass) -async def async_wait_recorder(hass: HomeAssistant) -> bool: - """Wait for recorder to initialize and return connection status. - - Returns False immediately if the recorder is not enabled. - """ - if DOMAIN not in hass.data: - return False - return await hass.data[DOMAIN].db_connected - - @functools.lru_cache(maxsize=1) def get_instance(hass: HomeAssistant) -> Recorder: """Get the recorder instance.""" diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index fd1f84a85ff..78812061a03 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -10,7 +10,7 @@ from typing import Any, Self, cast from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a67ef60c799..bf7a4a0971c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -12,11 +12,10 @@ from datetime import datetime, timedelta from functools import partial import itertools import logging -from types import MappingProxyType from typing import Any, Literal, TypedDict, cast, overload import async_interrupt -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from homeassistant import exceptions @@ -90,7 +89,7 @@ from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template -from .script_variables import ScriptVariables +from .script_variables import ScriptRunVariables, ScriptVariables from .template import Template from .trace import ( TraceElement, @@ -177,7 +176,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None: future.set_result(None) -def action_trace_append(variables: dict[str, Any], path: str) -> TraceElement: +def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: """Append a TraceElement to trace[path].""" trace_element = TraceElement(variables, path) trace_append_element(trace_element, ACTION_TRACE_NODE_MAX_LEN) @@ -189,7 +188,7 @@ async def trace_action( hass: HomeAssistant, script_run: _ScriptRun, stop: asyncio.Future[None], - variables: dict[str, Any], + variables: TemplateVarsType, ) -> AsyncGenerator[TraceElement]: """Trace action execution.""" path = trace_path_get() @@ -411,7 +410,7 @@ class _ScriptRun: self, hass: HomeAssistant, script: Script, - variables: dict[str, Any], + variables: ScriptRunVariables, context: Context | None, log_exceptions: bool, ) -> None: @@ -430,9 +429,6 @@ class _ScriptRun: if not self._stop.done(): self._script._changed() # noqa: SLF001 - async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType: - return await self._script._async_get_condition(config) # noqa: SLF001 - def _log( self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any ) -> None: @@ -488,14 +484,16 @@ class _ScriptRun: script_stack.pop() self._finish() - return ScriptRunResult(self._conversation_response, response, self._variables) + return ScriptRunResult( + self._conversation_response, response, self._variables.local_scope + ) async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): async with trace_action( - self._hass, self, self._stop, self._variables + self._hass, self, self._stop, self._variables.non_parallel_scope ) as trace_element: if self._stop.done(): return @@ -521,7 +519,7 @@ class _ScriptRun: trace_set_result(enabled=False) return - handler = f"_async_{action}_step" + handler = f"_async_step_{action}" try: await getattr(self, handler)() except Exception as ex: # noqa: BLE001 @@ -529,7 +527,7 @@ class _ScriptRun: ex, continue_on_error, self._log_exceptions or log_exceptions ) finally: - trace_element.update_variables(self._variables) + trace_element.update_variables(self._variables.non_parallel_scope) def _finish(self) -> None: self._script._runs.remove(self) # noqa: SLF001 @@ -614,107 +612,6 @@ class _ScriptRun: level=level, ) - def _get_pos_time_period_template(self, key: str) -> timedelta: - try: - return cv.positive_time_period( # type: ignore[no-any-return] - template.render_complex(self._action[key], self._variables) - ) - except (exceptions.TemplateError, vol.Invalid) as ex: - self._log( - "Error rendering %s %s template: %s", - self._script.name, - key, - ex, - level=logging.ERROR, - ) - raise _AbortScript from ex - - async def _async_delay_step(self) -> None: - """Handle delay.""" - delay_delta = self._get_pos_time_period_template(CONF_DELAY) - - self._step_log(f"delay {delay_delta}") - - delay = delay_delta.total_seconds() - self._changed() - if not delay: - # Handle an empty delay - trace_set_result(delay=delay, done=True) - return - - trace_set_result(delay=delay, done=False) - futures, timeout_handle, timeout_future = self._async_futures_with_timeout( - delay - ) - - try: - await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) - finally: - if timeout_future.done(): - trace_set_result(delay=delay, done=True) - else: - timeout_handle.cancel() - - def _get_timeout_seconds_from_action(self) -> float | None: - """Get the timeout from the action.""" - if CONF_TIMEOUT in self._action: - return self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() - return None - - async def _async_wait_template_step(self) -> None: - """Handle a wait template.""" - timeout = self._get_timeout_seconds_from_action() - self._step_log("wait template", timeout) - - self._variables["wait"] = {"remaining": timeout, "completed": False} - trace_set_result(wait=self._variables["wait"]) - - wait_template = self._action[CONF_WAIT_TEMPLATE] - - # check if condition already okay - if condition.async_template(self._hass, wait_template, self._variables, False): - self._variables["wait"]["completed"] = True - self._changed() - return - - if timeout == 0: - self._changed() - self._async_handle_timeout() - return - - futures, timeout_handle, timeout_future = self._async_futures_with_timeout( - timeout - ) - done = self._hass.loop.create_future() - futures.append(done) - - @callback - def async_script_wait( - entity_id: str, from_s: State | None, to_s: State | None - ) -> None: - """Handle script after template condition is true.""" - self._async_set_remaining_time_var(timeout_handle) - self._variables["wait"]["completed"] = True - _set_result_unless_done(done) - - unsub = async_track_template( - self._hass, wait_template, async_script_wait, self._variables - ) - self._changed() - await self._async_wait_with_optional_timeout( - futures, timeout_handle, timeout_future, unsub - ) - - def _async_set_remaining_time_var( - self, timeout_handle: asyncio.TimerHandle | None - ) -> None: - """Set the remaining time variable for a wait step.""" - wait_var = self._variables["wait"] - if timeout_handle: - wait_var["remaining"] = timeout_handle.when() - self._hass.loop.time() - else: - wait_var["remaining"] = None - async def _async_run_long_action[_T]( self, long_task: asyncio.Task[_T] ) -> _T | None: @@ -728,113 +625,54 @@ class _ScriptRun: except ScriptStoppedError as ex: raise asyncio.CancelledError from ex - async def _async_call_service_step(self) -> None: - """Call the service specified in the action.""" - self._step_log("call service") - - params = service.async_prepare_call_from_config( - self._hass, self._action, self._variables - ) - - # Validate response data parameters. This check ignores services that do - # not exist which will raise an appropriate error in the service call below. - response_variable = self._action.get(CONF_RESPONSE_VARIABLE) - return_response = response_variable is not None - if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]): - supports_response = self._hass.services.supports_response( - params[CONF_DOMAIN], params[CONF_SERVICE] - ) - if supports_response == SupportsResponse.ONLY and not return_response: - raise vol.Invalid( - f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data " - f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}" - ) - if supports_response == SupportsResponse.NONE and return_response: - raise vol.Invalid( - f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service " - f"'{CONF_RESPONSE_VARIABLE}' which does not support response data." - ) - - running_script = ( - params[CONF_DOMAIN] == "automation" - and params[CONF_SERVICE] == "trigger" - or params[CONF_DOMAIN] in ("python_script", "script") - ) - trace_set_result(params=params, running_script=running_script) - response_data = await self._async_run_long_action( + async def _async_run_script( + self, script: Script, *, parallel: bool = False + ) -> None: + """Execute a script.""" + result = await self._async_run_long_action( self._hass.async_create_task_internal( - self._hass.services.async_call( - **params, - blocking=True, - context=self._context, - return_response=return_response, + script.async_run( + self._variables.enter_scope(parallel=parallel), self._context ), eager_start=True, ) ) - if response_variable: - self._variables[response_variable] = response_data + if result and result.conversation_response is not UNDEFINED: + self._conversation_response = result.conversation_response - async def _async_device_step(self) -> None: - """Perform the device automation specified in the action.""" - self._step_log("device automation") - await device_action.async_call_action_from_config( - self._hass, self._action, self._variables, self._context + ## Flow control actions ## + + ### Sequence actions ### + + @async_trace_path("parallel") + async def _async_step_parallel(self) -> None: + """Run a sequence in parallel.""" + scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001 + + async def async_run_with_trace(idx: int, script: Script) -> None: + """Run a script with a trace path.""" + trace_path_stack_cv.set(copy(trace_path_stack_cv.get())) + with trace_path([str(idx), "sequence"]): + await self._async_run_script(script, parallel=True) + + results = await asyncio.gather( + *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), + return_exceptions=True, ) + for result in results: + if isinstance(result, Exception): + raise result - async def _async_scene_step(self) -> None: - """Activate the scene specified in the action.""" - self._step_log("activate scene") - trace_set_result(scene=self._action[CONF_SCENE]) - await self._hass.services.async_call( - scene.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: self._action[CONF_SCENE]}, - blocking=True, - context=self._context, - ) + @async_trace_path("sequence") + async def _async_step_sequence(self) -> None: + """Run a sequence.""" + sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001 + await self._async_run_script(sequence) - async def _async_event_step(self) -> None: - """Fire an event.""" - self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT])) - event_data = {} - for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE): - if conf not in self._action: - continue + ### Condition actions ### - try: - event_data.update( - template.render_complex(self._action[conf], self._variables) - ) - except exceptions.TemplateError as ex: - self._log( - "Error rendering event data template: %s", ex, level=logging.ERROR - ) - - trace_set_result(event=self._action[CONF_EVENT], event_data=event_data) - self._hass.bus.async_fire_internal( - self._action[CONF_EVENT], event_data, context=self._context - ) - - async def _async_condition_step(self) -> None: - """Test if condition is matching.""" - self._script.last_action = self._action.get( - CONF_ALIAS, self._action[CONF_CONDITION] - ) - cond = await self._async_get_condition(self._action) - try: - trace_element = trace_stack_top(trace_stack_cv) - if trace_element: - trace_element.reuse_by_child = True - check = cond(self._hass, self._variables) - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) - check = False - - self._log("Test condition %s: %s", self._script.last_action, check) - trace_update_result(result=check) - if not check: - raise _ConditionFail + async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType: + return await self._script._async_get_condition(config) # noqa: SLF001 def _test_conditions( self, @@ -863,14 +701,76 @@ class _ScriptRun: return traced_test_conditions(self._hass, self._variables) - @async_trace_path("repeat") - async def _async_repeat_step(self) -> None: # noqa: C901 - """Repeat a sequence.""" + async def _async_step_choose(self) -> None: + """Choose a sequence.""" + choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001 + + with trace_path("choose"): + for idx, (conditions, script) in enumerate(choose_data["choices"]): + with trace_path(str(idx)): + try: + if self._test_conditions(conditions, "choose", "conditions"): + trace_set_result(choice=idx) + with trace_path("sequence"): + await self._async_run_script(script) + return + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) + + if choose_data["default"] is not None: + trace_set_result(choice="default") + with trace_path(["default"]): + await self._async_run_script(choose_data["default"]) + + async def _async_step_condition(self) -> None: + """Test if condition is matching.""" + self._script.last_action = self._action.get( + CONF_ALIAS, self._action[CONF_CONDITION] + ) + cond = await self._async_get_condition(self._action) + try: + trace_element = trace_stack_top(trace_stack_cv) + if trace_element: + trace_element.reuse_by_child = True + check = cond(self._hass, self._variables) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) + check = False + + self._log("Test condition %s: %s", self._script.last_action, check) + trace_update_result(result=check) + if not check: + raise _ConditionFail + + async def _async_step_if(self) -> None: + """If sequence.""" + if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001 + + test_conditions: bool | None = False + try: + with trace_path("if"): + test_conditions = self._test_conditions( + if_data["if_conditions"], "if", "condition" + ) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'if' evaluation:\n%s", ex) + + if test_conditions: + trace_set_result(choice="then") + with trace_path("then"): + await self._async_run_script(if_data["if_then"]) + return + + if if_data["if_else"] is not None: + trace_set_result(choice="else") + with trace_path("else"): + await self._async_run_script(if_data["if_else"]) + + async def _async_do_step_repeat(self) -> None: # noqa: C901 + """Repeat a sequence helper.""" description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] - saved_repeat_vars = self._variables.get("repeat") - def set_repeat_var( iteration: int, count: int | None = None, item: Any = None ) -> None: @@ -879,7 +779,7 @@ class _ScriptRun: repeat_vars["last"] = iteration == count if item is not None: repeat_vars["item"] = item - self._variables["repeat"] = repeat_vars + self._variables.define_local("repeat", repeat_vars) script = self._script._get_repeat_script(self._step) # noqa: SLF001 warned_too_many_loops = False @@ -1030,55 +930,138 @@ class _ScriptRun: # while all the cpu time is consumed. await asyncio.sleep(0) - if saved_repeat_vars: - self._variables["repeat"] = saved_repeat_vars - else: - self._variables.pop("repeat", None) # Not set if count = 0 - - async def _async_choose_step(self) -> None: - """Choose a sequence.""" - choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001 - - with trace_path("choose"): - for idx, (conditions, script) in enumerate(choose_data["choices"]): - with trace_path(str(idx)): - try: - if self._test_conditions(conditions, "choose", "conditions"): - trace_set_result(choice=idx) - with trace_path("sequence"): - await self._async_run_script(script) - return - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) - - if choose_data["default"] is not None: - trace_set_result(choice="default") - with trace_path(["default"]): - await self._async_run_script(choose_data["default"]) - - async def _async_if_step(self) -> None: - """If sequence.""" - if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001 - - test_conditions: bool | None = False + @async_trace_path("repeat") + async def _async_step_repeat(self) -> None: + """Repeat a sequence.""" + self._variables = self._variables.enter_scope() try: - with trace_path("if"): - test_conditions = self._test_conditions( - if_data["if_conditions"], "if", "condition" + await self._async_do_step_repeat() + finally: + self._variables = self._variables.exit_scope() + + ### Stop actions ### + + async def _async_step_stop(self) -> None: + """Stop script execution.""" + stop = self._action[CONF_STOP] + error = self._action.get(CONF_ERROR, False) + trace_set_result(stop=stop, error=error) + if error: + self._log("Error script sequence: %s", stop) + raise _AbortScript(stop) + + self._log("Stop script sequence: %s", stop) + if CONF_RESPONSE_VARIABLE in self._action: + try: + response = self._variables[self._action[CONF_RESPONSE_VARIABLE]] + except KeyError as ex: + raise _AbortScript( + f"Response variable '{self._action[CONF_RESPONSE_VARIABLE]}' " + "is not defined" + ) from ex + else: + response = None + raise _StopScript(stop, response) + + ## Variable actions ## + + async def _async_step_variables(self) -> None: + """Define a local variable.""" + self._step_log("defining local variables") + for key, value in ( + self._action[CONF_VARIABLES].async_simple_render(self._variables).items() + ): + self._variables.define_local(key, value) + + ## External actions ## + + async def _async_step_call_service(self) -> None: + """Call the service specified in the action.""" + self._step_log("call service") + + params = service.async_prepare_call_from_config( + self._hass, self._action, self._variables + ) + + # Validate response data parameters. This check ignores services that do + # not exist which will raise an appropriate error in the service call below. + response_variable = self._action.get(CONF_RESPONSE_VARIABLE) + return_response = response_variable is not None + if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]): + supports_response = self._hass.services.supports_response( + params[CONF_DOMAIN], params[CONF_SERVICE] + ) + if supports_response == SupportsResponse.ONLY and not return_response: + raise vol.Invalid( + f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data " + f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}" + ) + if supports_response == SupportsResponse.NONE and return_response: + raise vol.Invalid( + f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service " + f"'{CONF_RESPONSE_VARIABLE}' which does not support response data." ) - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'if' evaluation:\n%s", ex) - if test_conditions: - trace_set_result(choice="then") - with trace_path("then"): - await self._async_run_script(if_data["if_then"]) - return + running_script = ( + params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" + ) or params[CONF_DOMAIN] in ("python_script", "script") + trace_set_result(params=params, running_script=running_script) + response_data = await self._async_run_long_action( + self._hass.async_create_task_internal( + self._hass.services.async_call( + **params, + blocking=True, + context=self._context, + return_response=return_response, + ), + eager_start=True, + ) + ) + if response_variable: + self._variables[response_variable] = response_data - if if_data["if_else"] is not None: - trace_set_result(choice="else") - with trace_path("else"): - await self._async_run_script(if_data["if_else"]) + async def _async_step_device(self) -> None: + """Perform the device automation specified in the action.""" + self._step_log("device automation") + await device_action.async_call_action_from_config( + self._hass, self._action, dict(self._variables), self._context + ) + + async def _async_step_event(self) -> None: + """Fire an event.""" + self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT])) + event_data = {} + for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE): + if conf not in self._action: + continue + + try: + event_data.update( + template.render_complex(self._action[conf], self._variables) + ) + except exceptions.TemplateError as ex: + self._log( + "Error rendering event data template: %s", ex, level=logging.ERROR + ) + + trace_set_result(event=self._action[CONF_EVENT], event_data=event_data) + self._hass.bus.async_fire_internal( + self._action[CONF_EVENT], event_data, context=self._context + ) + + async def _async_step_scene(self) -> None: + """Activate the scene specified in the action.""" + self._step_log("activate scene") + trace_set_result(scene=self._action[CONF_SCENE]) + await self._hass.services.async_call( + scene.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self._action[CONF_SCENE]}, + blocking=True, + context=self._context, + ) + + ## Time-based actions ## @overload def _async_futures_with_timeout( @@ -1126,18 +1109,103 @@ class _ScriptRun: futures.append(timeout_future) return futures, timeout_handle, timeout_future - async def _async_wait_for_trigger_step(self) -> None: + def _get_pos_time_period_template(self, key: str) -> timedelta: + try: + return cv.positive_time_period( # type: ignore[no-any-return] + template.render_complex(self._action[key], self._variables) + ) + except (exceptions.TemplateError, vol.Invalid) as ex: + self._log( + "Error rendering %s %s template: %s", + self._script.name, + key, + ex, + level=logging.ERROR, + ) + raise _AbortScript from ex + + async def _async_step_delay(self) -> None: + """Handle delay.""" + delay_delta = self._get_pos_time_period_template(CONF_DELAY) + + self._step_log(f"delay {delay_delta}") + + delay = delay_delta.total_seconds() + self._changed() + if not delay: + # Handle an empty delay + trace_set_result(delay=delay, done=True) + return + + trace_set_result(delay=delay, done=False) + futures, timeout_handle, timeout_future = self._async_futures_with_timeout( + delay + ) + + try: + await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) + finally: + if timeout_future.done(): + trace_set_result(delay=delay, done=True) + else: + timeout_handle.cancel() + + def _get_timeout_seconds_from_action(self) -> float | None: + """Get the timeout from the action.""" + if CONF_TIMEOUT in self._action: + return self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() + return None + + def _async_handle_timeout(self) -> None: + """Handle timeout.""" + self._variables["wait"]["remaining"] = 0.0 + if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): + self._log(_TIMEOUT_MSG) + trace_set_result(wait=self._variables["wait"], timeout=True) + raise _AbortScript from TimeoutError() + + async def _async_wait_with_optional_timeout( + self, + futures: list[asyncio.Future[None]], + timeout_handle: asyncio.TimerHandle | None, + timeout_future: asyncio.Future[None] | None, + unsub: Callable[[], None], + ) -> None: + try: + await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) + if timeout_future and timeout_future.done(): + self._async_handle_timeout() + finally: + if timeout_future and not timeout_future.done() and timeout_handle: + timeout_handle.cancel() + + unsub() + + def _async_set_remaining_time_var( + self, timeout_handle: asyncio.TimerHandle | None + ) -> None: + """Set the remaining time variable for a wait step.""" + wait_var = self._variables["wait"] + if timeout_handle: + wait_var["remaining"] = timeout_handle.when() - self._hass.loop.time() + else: + wait_var["remaining"] = None + + async def _async_step_wait_for_trigger(self) -> None: """Wait for a trigger event.""" timeout = self._get_timeout_seconds_from_action() self._step_log("wait for trigger", timeout) - variables = {**self._variables} - self._variables["wait"] = { - "remaining": timeout, - "completed": False, - "trigger": None, - } + variables = dict(self._variables) + self._variables.assign_parallel_protected( + "wait", + { + "remaining": timeout, + "completed": False, + "trigger": None, + }, + ) trace_set_result(wait=self._variables["wait"]) if timeout == 0: @@ -1178,39 +1246,55 @@ class _ScriptRun: futures, timeout_handle, timeout_future, remove_triggers ) - def _async_handle_timeout(self) -> None: - """Handle timeout.""" - self._variables["wait"]["remaining"] = 0.0 - if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): - self._log(_TIMEOUT_MSG) - trace_set_result(wait=self._variables["wait"], timeout=True) - raise _AbortScript from TimeoutError() + async def _async_step_wait_template(self) -> None: + """Handle a wait template.""" + timeout = self._get_timeout_seconds_from_action() + self._step_log("wait template", timeout) - async def _async_wait_with_optional_timeout( - self, - futures: list[asyncio.Future[None]], - timeout_handle: asyncio.TimerHandle | None, - timeout_future: asyncio.Future[None] | None, - unsub: Callable[[], None], - ) -> None: - try: - await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) - if timeout_future and timeout_future.done(): - self._async_handle_timeout() - finally: - if timeout_future and not timeout_future.done() and timeout_handle: - timeout_handle.cancel() + self._variables.assign_parallel_protected( + "wait", {"remaining": timeout, "completed": False} + ) + trace_set_result(wait=self._variables["wait"]) - unsub() + wait_template = self._action[CONF_WAIT_TEMPLATE] - async def _async_variables_step(self) -> None: - """Set a variable value.""" - self._step_log("setting variables") - self._variables = self._action[CONF_VARIABLES].async_render( - self._hass, self._variables, render_as_defaults=False + # check if condition already okay + if condition.async_template(self._hass, wait_template, self._variables, False): + self._variables["wait"]["completed"] = True + self._changed() + return + + if timeout == 0: + self._changed() + self._async_handle_timeout() + return + + futures, timeout_handle, timeout_future = self._async_futures_with_timeout( + timeout + ) + done = self._hass.loop.create_future() + futures.append(done) + + @callback + def async_script_wait( + entity_id: str, from_s: State | None, to_s: State | None + ) -> None: + """Handle script after template condition is true.""" + self._async_set_remaining_time_var(timeout_handle) + self._variables["wait"]["completed"] = True + _set_result_unless_done(done) + + unsub = async_track_template( + self._hass, wait_template, async_script_wait, self._variables + ) + self._changed() + await self._async_wait_with_optional_timeout( + futures, timeout_handle, timeout_future, unsub ) - async def _async_set_conversation_response_step(self) -> None: + ## Conversation actions ## + + async def _async_step_set_conversation_response(self) -> None: """Set conversation response.""" self._step_log("setting conversation response") resp: template.Template | None = self._action[CONF_SET_CONVERSATION_RESPONSE] @@ -1222,63 +1306,6 @@ class _ScriptRun: ) trace_set_result(conversation_response=self._conversation_response) - async def _async_stop_step(self) -> None: - """Stop script execution.""" - stop = self._action[CONF_STOP] - error = self._action.get(CONF_ERROR, False) - trace_set_result(stop=stop, error=error) - if error: - self._log("Error script sequence: %s", stop) - raise _AbortScript(stop) - - self._log("Stop script sequence: %s", stop) - if CONF_RESPONSE_VARIABLE in self._action: - try: - response = self._variables[self._action[CONF_RESPONSE_VARIABLE]] - except KeyError as ex: - raise _AbortScript( - f"Response variable '{self._action[CONF_RESPONSE_VARIABLE]}' " - "is not defined" - ) from ex - else: - response = None - raise _StopScript(stop, response) - - @async_trace_path("sequence") - async def _async_sequence_step(self) -> None: - """Run a sequence.""" - sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001 - await self._async_run_script(sequence) - - @async_trace_path("parallel") - async def _async_parallel_step(self) -> None: - """Run a sequence in parallel.""" - scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001 - - async def async_run_with_trace(idx: int, script: Script) -> None: - """Run a script with a trace path.""" - trace_path_stack_cv.set(copy(trace_path_stack_cv.get())) - with trace_path([str(idx), "sequence"]): - await self._async_run_script(script) - - results = await asyncio.gather( - *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), - return_exceptions=True, - ) - for result in results: - if isinstance(result, Exception): - raise result - - async def _async_run_script(self, script: Script) -> None: - """Execute a script.""" - result = await self._async_run_long_action( - self._hass.async_create_task_internal( - script.async_run(self._variables, self._context), eager_start=True - ) - ) - if result and result.conversation_response is not UNDEFINED: - self._conversation_response = result.conversation_response - class _QueuedScriptRun(_ScriptRun): """Manage queued Script sequence run.""" @@ -1355,7 +1382,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> ) -type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any] +type _VarsType = dict[str, Any] | Mapping[str, Any] | ScriptRunVariables def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: @@ -1393,7 +1420,7 @@ class ScriptRunResult: conversation_response: str | None | UndefinedType service_response: ServiceResponse - variables: dict[str, Any] + variables: Mapping[str, Any] class Script: @@ -1408,7 +1435,6 @@ class Script: *, # Used in "Running " log message change_listener: Callable[[], Any] | None = None, - copy_variables: bool = False, log_exceptions: bool = True, logger: logging.Logger | None = None, max_exceeded: str = DEFAULT_MAX_EXCEEDED, @@ -1462,8 +1488,6 @@ class Script: self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} self.variables = variables - self._variables_dynamic = template.is_complex(variables) - self._copy_variables_on_run = copy_variables @property def change_listener(self) -> Callable[..., Any] | None: @@ -1589,6 +1613,9 @@ class Script: target, referenced, script[CONF_SEQUENCE] ) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" @@ -1636,6 +1663,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_devices(referenced, step[CONF_SEQUENCE]) + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -1684,6 +1714,9 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) + elif action == cv.SCRIPT_ACTION_SEQUENCE: + Script._find_referenced_entities(referenced, step[CONF_SEQUENCE]) + def run( self, variables: _VarsType | None = None, context: Context | None = None ) -> None: @@ -1732,25 +1765,19 @@ class Script: if self.top_level: if self.variables: try: - variables = self.variables.async_render( + run_variables = self.variables.async_render( self._hass, run_variables, ) except exceptions.TemplateError as err: self._log("Error rendering variables: %s", err, level=logging.ERROR) raise - elif run_variables: - variables = dict(run_variables) - else: - variables = {} + variables = ScriptRunVariables.create_top_level(run_variables) variables["context"] = context - elif self._copy_variables_on_run: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], copy(run_variables)) else: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], run_variables) + # This is not the top level script, run_variables is an instance of ScriptRunVariables + variables = cast(ScriptRunVariables, run_variables) # Prevent non-allowed recursive calls which will cause deadlocks when we try to # stop (restart) or wait for (queued) our own script run. @@ -1770,7 +1797,7 @@ class Script: f"{self.domain}.{self.name} which is already running " "in the current execution path; " "Traceback (most recent call last):\n" - f"{"\n".join(formatted_stack)}", + f"{'\n'.join(formatted_stack)}", level=logging.WARNING, ) return None @@ -1834,7 +1861,7 @@ class Script: def _prep_repeat_script(self, step: int) -> Script: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Repeat at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Repeat at step {step + 1}") sub_script = Script( self._hass, action[CONF_REPEAT][CONF_SEQUENCE], @@ -1857,7 +1884,7 @@ class Script: async def _async_prep_choose_data(self, step: int) -> _ChooseData: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Choose at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Choose at step {step + 1}") choices = [] for idx, choice in enumerate(action[CONF_CHOOSE], start=1): conditions = [ @@ -1911,7 +1938,7 @@ class Script: async def _async_prep_if_data(self, step: int) -> _IfData: """Prepare data for an if statement.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"If at step {step+1}") + step_name = action.get(CONF_ALIAS, f"If at step {step + 1}") conditions = [ await self._async_get_condition(config) for config in action[CONF_IF] @@ -1962,7 +1989,7 @@ class Script: async def _async_prep_parallel_scripts(self, step: int) -> list[Script]: action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Parallel action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Parallel action at step {step + 1}") parallel_scripts: list[Script] = [] for idx, parallel_script in enumerate(action[CONF_PARALLEL], start=1): parallel_name = parallel_script.get(CONF_ALIAS, f"parallel {idx}") @@ -1976,7 +2003,6 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, - copy_variables=True, ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script @@ -1994,7 +2020,7 @@ class Script: async def _async_prep_sequence_script(self, step: int) -> Script: """Prepare a sequence script.""" action = self.sequence[step] - step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}") + step_name = action.get(CONF_ALIAS, f"Sequence action at step {step + 1}") sequence_script = Script( self._hass, diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 2b4507abd64..54200e094e6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections import ChainMap, UserDict from collections.abc import Mapping -from typing import Any +from dataclasses import dataclass, field +from typing import Any, cast from homeassistant.core import HomeAssistant, callback @@ -24,30 +26,23 @@ class ScriptVariables: hass: HomeAssistant, run_variables: Mapping[str, Any] | None, *, - render_as_defaults: bool = True, limited: bool = False, ) -> dict[str, Any]: """Render script variables. - The run variables are used to compute the static variables. - - If `render_as_defaults` is True, the run variables will not be overridden. - + The run variables are included in the result. + The run variables are used to compute the rendered variable values. + The run variables will not be overridden. + The rendering happens one at a time, with previous results influencing the next. """ if self._has_template is None: self._has_template = template.is_complex(self.variables) if not self._has_template: - if render_as_defaults: - rendered_variables = dict(self.variables) + rendered_variables = dict(self.variables) - if run_variables is not None: - rendered_variables.update(run_variables) - else: - rendered_variables = ( - {} if run_variables is None else dict(run_variables) - ) - rendered_variables.update(self.variables) + if run_variables is not None: + rendered_variables.update(run_variables) return rendered_variables @@ -56,7 +51,7 @@ class ScriptVariables: for key, value in self.variables.items(): # We can skip if we're going to override this key with # run variables anyway - if render_as_defaults and key in rendered_variables: + if key in rendered_variables: continue rendered_variables[key] = template.render_complex( @@ -65,6 +60,197 @@ class ScriptVariables: return rendered_variables + @callback + def async_simple_render(self, run_variables: Mapping[str, Any]) -> dict[str, Any]: + """Render script variables. + + Simply renders the variables, the run variables are not included in the result. + The run variables are used to compute the rendered variable values. + The rendering happens one at a time, with previous results influencing the next. + """ + if self._has_template is None: + self._has_template = template.is_complex(self.variables) + + if not self._has_template: + return self.variables + + run_variables = dict(run_variables) + rendered_variables = {} + + for key, value in self.variables.items(): + rendered_variable = template.render_complex(value, run_variables) + rendered_variables[key] = rendered_variable + run_variables[key] = rendered_variable + + return rendered_variables + def as_dict(self) -> dict[str, Any]: """Return dict version of this class.""" return self.variables + + +@dataclass +class _ParallelData: + """Data used in each parallel sequence.""" + + # `protected` is for variables that need special protection in parallel sequences. + # What this means is that such a variable defined in one parallel sequence will not be + # clobbered by the variable with the same name assigned in another parallel sequence. + # It also means that such a variable will not be visible in the outer scope. + # Currently the only such variable is `wait`. + protected: dict[str, Any] = field(default_factory=dict) + # `outer_scope_writes` is for variables that are written to the outer scope from + # a parallel sequence. This is used for generating correct traces of changed variables + # for each of the parallel sequences, isolating them from one another. + outer_scope_writes: dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class ScriptRunVariables(UserDict[str, Any]): + """Class to hold script run variables. + + The purpose of this class is to provide proper variable scoping semantics for scripts. + Each instance institutes a new local scope, in which variables can be defined. + Each instance has a reference to the previous instance, except for the top-level instance. + The instances therefore form a chain, in which variable lookup and assignment is performed. + The variables defined lower in the chain naturally override those defined higher up. + """ + + # _previous is the previous ScriptRunVariables in the chain + _previous: ScriptRunVariables | None = None + # _parent is the previous non-empty ScriptRunVariables in the chain + _parent: ScriptRunVariables | None = None + + # _local_data is the store for local variables + _local_data: dict[str, Any] | None = None + # _parallel_data is used for each parallel sequence + _parallel_data: _ParallelData | None = None + + # _non_parallel_scope includes all scopes all the way to the most recent parallel split + _non_parallel_scope: ChainMap[str, Any] + # _full_scope includes all scopes (all the way to the top-level) + _full_scope: ChainMap[str, Any] + + @classmethod + def create_top_level( + cls, + initial_data: Mapping[str, Any] | None = None, + ) -> ScriptRunVariables: + """Create a new top-level ScriptRunVariables.""" + local_data: dict[str, Any] = {} + non_parallel_scope = full_scope = ChainMap(local_data) + self = cls( + _local_data=local_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + if initial_data is not None: + self.update(initial_data) + return self + + def enter_scope(self, *, parallel: bool = False) -> ScriptRunVariables: + """Return a new child scope. + + :param parallel: Whether the new scope starts a parallel sequence. + """ + if self._local_data is not None or self._parallel_data is not None: + parent = self + else: + parent = cast( # top level always has local data, so we can cast safely + ScriptRunVariables, self._parent + ) + + parallel_data: _ParallelData | None + if not parallel: + parallel_data = None + non_parallel_scope = self._non_parallel_scope + full_scope = self._full_scope + else: + parallel_data = _ParallelData() + non_parallel_scope = ChainMap( + parallel_data.protected, parallel_data.outer_scope_writes + ) + full_scope = self._full_scope.new_child(parallel_data.protected) + + return ScriptRunVariables( + _previous=self, + _parent=parent, + _parallel_data=parallel_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + + def exit_scope(self) -> ScriptRunVariables: + """Exit the current scope. + + Does no clean-up, but simply returns the previous scope. + """ + if self._previous is None: + raise ValueError("Cannot exit top-level scope") + return self._previous + + def __delitem__(self, key: str) -> None: + """Delete a variable (disallowed).""" + raise TypeError("Deleting items is not allowed in ScriptRunVariables.") + + def __setitem__(self, key: str, value: Any) -> None: + """Assign value to a variable.""" + self._assign(key, value, parallel_protected=False) + + def assign_parallel_protected(self, key: str, value: Any) -> None: + """Assign value to a variable which is to be protected in parallel sequences.""" + self._assign(key, value, parallel_protected=True) + + def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None: + """Assign value to a variable. + + Value is always assigned to the variable in the nearest scope, in which it is defined. + If the variable is not defined at all, it is created in the top-level scope. + + :param parallel_protected: Whether variable is to be protected in parallel sequences. + """ + if self._local_data is not None and key in self._local_data: + self._local_data[key] = value + return + + if self._parent is None: + assert self._local_data is not None # top level always has local data + self._local_data[key] = value + return + + if self._parallel_data is not None: + if parallel_protected: + self._parallel_data.protected[key] = value + return + self._parallel_data.protected.pop(key, None) + self._parallel_data.outer_scope_writes[key] = value + + self._parent._assign(key, value, parallel_protected=parallel_protected) # noqa: SLF001 + + def define_local(self, key: str, value: Any) -> None: + """Define a local variable and assign value to it.""" + if self._local_data is None: + self._local_data = {} + self._non_parallel_scope = self._non_parallel_scope.new_child( + self._local_data + ) + self._full_scope = self._full_scope.new_child(self._local_data) + self._local_data[key] = value + + @property + def data(self) -> Mapping[str, Any]: # type: ignore[override] + """Return variables in full scope. + + Defined here for UserDict compatibility. + """ + return self._full_scope + + @property + def non_parallel_scope(self) -> Mapping[str, Any]: + """Return variables in non-parallel scope.""" + return self._non_parallel_scope + + @property + def local_scope(self) -> Mapping[str, Any]: + """Return variables in local scope.""" + return self._local_data if self._local_data is not None else {} diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 35135010452..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, @@ -133,8 +135,7 @@ def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: domain, enum, option = option_or_feature.split(".", 2) except ValueError as exc: raise vol.Invalid( - f"Invalid {label} '{option_or_feature}', expected " - ".." + f"Invalid {label} '{option_or_feature}', expected .." ) from exc base_components = _base_components() @@ -226,7 +227,7 @@ class ServiceParams(TypedDict): class ServiceTargetSelector: """Class to hold a target selector for a service.""" - __slots__ = ("entity_ids", "device_ids", "area_ids", "floor_ids", "label_ids") + __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" @@ -503,7 +504,7 @@ def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: @bind_hass -def async_extract_referenced_entity_ids( # noqa: C901 +def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py new file mode 100644 index 00000000000..47479a53a8a --- /dev/null +++ b/homeassistant/helpers/service_info/dhcp.py @@ -0,0 +1,14 @@ +"""DHCP discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class DhcpServiceInfo(BaseServiceInfo): + """Prepared info from dhcp entries.""" + + ip: str + hostname: str + macaddress: str diff --git a/homeassistant/helpers/service_info/ssdp.py b/homeassistant/helpers/service_info/ssdp.py new file mode 100644 index 00000000000..4a3a5a24474 --- /dev/null +++ b/homeassistant/helpers/service_info/ssdp.py @@ -0,0 +1,41 @@ +"""SSDP discovery data.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for accessing info from retrieved UPnP device description +ATTR_ST: Final = "st" +ATTR_NT: Final = "nt" +ATTR_UPNP_DEVICE_TYPE: Final = "deviceType" +ATTR_UPNP_FRIENDLY_NAME: Final = "friendlyName" +ATTR_UPNP_MANUFACTURER: Final = "manufacturer" +ATTR_UPNP_MANUFACTURER_URL: Final = "manufacturerURL" +ATTR_UPNP_MODEL_DESCRIPTION: Final = "modelDescription" +ATTR_UPNP_MODEL_NAME: Final = "modelName" +ATTR_UPNP_MODEL_NUMBER: Final = "modelNumber" +ATTR_UPNP_MODEL_URL: Final = "modelURL" +ATTR_UPNP_SERIAL: Final = "serialNumber" +ATTR_UPNP_SERVICE_LIST: Final = "serviceList" +ATTR_UPNP_UDN: Final = "UDN" +ATTR_UPNP_UPC: Final = "UPC" +ATTR_UPNP_PRESENTATION_URL: Final = "presentationURL" + + +@dataclass(slots=True) +class SsdpServiceInfo(BaseServiceInfo): + """Prepared info from ssdp/upnp entries.""" + + ssdp_usn: str + ssdp_st: str + upnp: Mapping[str, Any] + ssdp_location: str | None = None + ssdp_nt: str | None = None + ssdp_udn: str | None = None + ssdp_ext: str | None = None + ssdp_server: str | None = None + ssdp_headers: Mapping[str, Any] = field(default_factory=dict) + ssdp_all_locations: set[str] = field(default_factory=set) + x_homeassistant_matching_domains: set[str] = field(default_factory=set) diff --git a/homeassistant/helpers/service_info/usb.py b/homeassistant/helpers/service_info/usb.py new file mode 100644 index 00000000000..c7d6f6ea143 --- /dev/null +++ b/homeassistant/helpers/service_info/usb.py @@ -0,0 +1,17 @@ +"""USB discovery data.""" + +from dataclasses import dataclass + +from homeassistant.data_entry_flow import BaseServiceInfo + + +@dataclass(slots=True) +class UsbServiceInfo(BaseServiceInfo): + """Prepared info from usb entries.""" + + device: str + vid: str + pid: str + serial_number: str | None + manufacturer: str | None + description: str | None diff --git a/homeassistant/helpers/service_info/zeroconf.py b/homeassistant/helpers/service_info/zeroconf.py new file mode 100644 index 00000000000..a91bc5e77d9 --- /dev/null +++ b/homeassistant/helpers/service_info/zeroconf.py @@ -0,0 +1,50 @@ +"""Zeroconf discovery data.""" + +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address +from typing import Any, Final + +from homeassistant.data_entry_flow import BaseServiceInfo + +# Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] +ATTR_PROPERTIES_ID: Final = "id" + + +@dataclass(slots=True) +class ZeroconfServiceInfo(BaseServiceInfo): + """Prepared info from mDNS entries. + + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] + port: int | None + hostname: str + type: str + name: str + properties: dict[str, Any] + + @property + def host(self) -> str: + """Return the host.""" + return str(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [str(ip_address) for ip_address in self.ip_addresses] diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 20e4ee82162..075fc50b49a 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -3,15 +3,22 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine import functools -from typing import Any, cast, overload +from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass from homeassistant.util.hass_dict import HassKey type _FuncType[_T] = Callable[[HomeAssistant], _T] +type _Coro[_T] = Coroutine[Any, Any, _T] + + +@overload +def singleton[_T]( + data_key: HassKey[_T], *, async_: Literal[True] +) -> Callable[[_FuncType[_Coro[_T]]], _FuncType[_Coro[_T]]]: ... @overload @@ -24,29 +31,37 @@ def singleton[_T]( def singleton[_T](data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ... -def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: +def singleton[_S, _T, _U]( + data_key: Any, *, async_: bool = False +) -> Callable[[_FuncType[_S]], _FuncType[_S]]: """Decorate a function that should be called once per instance. Result will be cached and simultaneous calls will be handled. """ - def wrapper(func: _FuncType[_T]) -> _FuncType[_T]: + @overload + def wrapper(func: _FuncType[_Coro[_T]]) -> _FuncType[_Coro[_T]]: ... + + @overload + def wrapper(func: _FuncType[_U]) -> _FuncType[_U]: ... + + def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" if not asyncio.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass @functools.wraps(func) - def wrapped(hass: HomeAssistant) -> _T: + def wrapped(hass: HomeAssistant) -> _U: if data_key not in hass.data: hass.data[data_key] = func(hass) - return cast(_T, hass.data[data_key]) + return cast(_U, hass.data[data_key]) return wrapped @bind_hass @functools.wraps(func) - async def async_wrapped(hass: HomeAssistant) -> Any: + async def async_wrapped(hass: HomeAssistant) -> _T: if data_key not in hass.data: evt = hass.data[data_key] = asyncio.Event() result = await func(hass) @@ -62,6 +77,45 @@ def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: return cast(_T, obj_or_evt) - return async_wrapped # type: ignore[return-value] + return async_wrapped return wrapper + + +async def _test_singleton_typing(hass: HomeAssistant) -> None: + """Test singleton overloads work as intended. + + This is tested during the mypy run. Do not move it to 'tests'! + """ + # Test HassKey + key = HassKey[int]("key") + + @singleton(key) + def func(hass: HomeAssistant) -> int: + return 2 + + @singleton(key, async_=True) + async def async_func(hass: HomeAssistant) -> int: + return 2 + + assert_type(func(hass), int) + assert_type(await async_func(hass), int) + + # Test invalid use of 'async_' with sync function + @singleton(key, async_=True) # type: ignore[arg-type] + def func_error(hass: HomeAssistant) -> int: + return 2 + + # Test string key + other_key = "key" + + @singleton(other_key) + def func2(hass: HomeAssistant) -> str: + return "" + + @singleton(other_key) + async def async_func2(hass: HomeAssistant) -> str: + return "" + + assert_type(func2(hass), str) + assert_type(await async_func2(hass), str) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 080599f54d8..fe94be68763 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -13,7 +13,7 @@ import os from pathlib import Path from typing import Any -from propcache import cached_property +from propcache.api import cached_property from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, @@ -30,8 +30,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util import json as json_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util.file import WriteError from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5b4a48bb07c..7dc3097cdb3 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -44,7 +44,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace from lru import LRU import orjson -from propcache import under_cached_property +from propcache.api import under_cached_property import voluptuous as vol from homeassistant.const import ( @@ -74,7 +74,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import ( convert, dt as dt_util, - location as loc_util, + location as location_util, slugify as slugify_util, ) from homeassistant.util.async_ import run_callback_threadsafe @@ -386,19 +386,19 @@ class RenderInfo: """Holds information about a template render.""" __slots__ = ( - "template", - "filter_lifecycle", - "filter", "_result", - "is_static", - "exception", "all_states", "all_states_lifecycle", "domains", "domains_lifecycle", "entities", - "rate_limit", + "exception", + "filter", + "filter_lifecycle", "has_time", + "is_static", + "rate_limit", + "template", ) def __init__(self, template: Template) -> None: @@ -507,17 +507,17 @@ class Template: __slots__ = ( "__weakref__", - "template", + "_compiled", + "_compiled_code", + "_exc_info", + "_hash_cache", + "_limited", + "_log_fn", + "_renders", + "_strict", "hass", "is_static", - "_compiled_code", - "_compiled", - "_exc_info", - "_limited", - "_strict", - "_log_fn", - "_hash_cache", - "_renders", + "template", ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @@ -601,7 +601,7 @@ class Template: or filter depending on hass or the state machine. """ if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) assert self.hass is not None, "hass variable not set on template" @@ -630,7 +630,7 @@ class Template: self._renders += 1 if self.is_static: - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return self.template return self._parse_result(self.template) @@ -651,7 +651,7 @@ class Template: render_result = render_result.strip() - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -826,7 +826,7 @@ class Template: ) return value if error_value is _SENTINEL else error_value - if not parse_result or self.hass and self.hass.config.legacy_templates: + if not parse_result or (self.hass and self.hass.config.legacy_templates): return render_result return self._parse_result(render_result) @@ -841,16 +841,16 @@ class Template: self.ensure_valid() assert self.hass is not None, "hass variable not set on template" - assert ( - self._limited is None or self._limited == limited - ), "can't change between limited and non limited template" - assert ( - self._strict is None or self._strict == strict - ), "can't change between strict and non strict template" + assert self._limited is None or self._limited == limited, ( + "can't change between limited and non limited template" + ) + assert self._strict is None or self._strict == strict, ( + "can't change between strict and non strict template" + ) assert not (strict and limited), "can't combine strict and limited template" - assert ( - self._log_fn is None or self._log_fn == log_fn - ), "can't change custom log function" + assert self._log_fn is None or self._log_fn == log_fn, ( + "can't change custom log function" + ) assert self._compiled_code is not None, "template code was not compiled" self._limited = limited @@ -991,7 +991,7 @@ class StateTranslated: class DomainStates: """Class to expose a specific HA domain as attributes.""" - __slots__ = ("_hass", "_domain") + __slots__ = ("_domain", "_hass") __setitem__ = _readonly __delitem__ = _readonly @@ -1035,7 +1035,7 @@ class DomainStates: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "_state") + __slots__ = ("_collect", "_entity_id", "_hass", "_state") _state: State @@ -1525,6 +1525,15 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries if entry.id] +def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: + """Return entity_ids for a given floor ID or name.""" + return [ + entity_id + for area_id in floor_areas(hass, floor_id_or_name) + for entity_id in area_entities(hass, area_id) + ] + + def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" return list(area_registry.async_get(hass).areas) @@ -1735,7 +1744,7 @@ def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: return [entry.entity_id for entry in entries] -def closest(hass, *args): +def closest(hass: HomeAssistant, *args: Any) -> State | None: """Find closest entity. Closest to home: @@ -1775,21 +1784,24 @@ def closest(hass, *args): ) return None - latitude = point_state.attributes.get(ATTR_LATITUDE) - longitude = point_state.attributes.get(ATTR_LONGITUDE) + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] entities = args[1] else: - latitude = convert(args[0], float) - longitude = convert(args[1], float) + latitude_arg = convert(args[0], float) + longitude_arg = convert(args[1], float) - if latitude is None or longitude is None: + if latitude_arg is None or longitude_arg is None: _LOGGER.warning( "Closest:Received invalid coordinates: %s, %s", args[0], args[1] ) return None + latitude = latitude_arg + longitude = longitude_arg + entities = args[2] states = expand(hass, entities) @@ -1798,20 +1810,20 @@ def closest(hass, *args): return loc_helper.closest(latitude, longitude, states) -def closest_filter(hass, *args): +def closest_filter(hass: HomeAssistant, *args: Any) -> State | None: """Call closest as a filter. Need to reorder arguments.""" new_args = list(args[1:]) new_args.append(args[0]) return closest(hass, *new_args) -def distance(hass, *args): +def distance(hass: HomeAssistant, *args: Any) -> float | None: """Calculate distance. Will calculate distance from home to a point or between points. Points can be passed in using state objects or lat/lng coordinates. """ - locations = [] + locations: list[tuple[float, float]] = [] to_process = list(args) @@ -1831,10 +1843,10 @@ def distance(hass, *args): return None value_2 = to_process.pop(0) - latitude = convert(value, float) - longitude = convert(value_2, float) + latitude_to_process = convert(value, float) + longitude_to_process = convert(value_2, float) - if latitude is None or longitude is None: + if latitude_to_process is None or longitude_to_process is None: _LOGGER.warning( "Distance:Unable to process latitude and longitude: %s, %s", value, @@ -1842,6 +1854,9 @@ def distance(hass, *args): ) return None + latitude = latitude_to_process + longitude = longitude_to_process + else: if not loc_helper.has_location(point_state): _LOGGER.warning( @@ -1849,8 +1864,8 @@ def distance(hass, *args): ) return None - latitude = point_state.attributes.get(ATTR_LATITUDE) - longitude = point_state.attributes.get(ATTR_LONGITUDE) + latitude = point_state.attributes[ATTR_LATITUDE] + longitude = point_state.attributes[ATTR_LONGITUDE] locations.append((latitude, longitude)) @@ -1858,7 +1873,7 @@ def distance(hass, *args): return hass.config.distance(*locations[0]) return hass.config.units.length( - loc_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS + location_util.distance(*locations[0] + locations[1]), UnitOfLength.METERS ) @@ -1873,14 +1888,19 @@ def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> boo """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) return state_obj is not None and ( - state_obj.state == state or isinstance(state, list) and state_obj.state in state + state_obj.state == state + or (isinstance(state, list) and state_obj.state in state) ) def is_state_attr(hass: HomeAssistant, entity_id: str, name: str, value: Any) -> bool: """Test if a state's attribute is a specific value.""" - attr = state_attr(hass, entity_id, name) - return attr is not None and attr == value + if (state_obj := _get_state(hass, entity_id)) is not None: + attr = state_obj.attributes.get(name, _SENTINEL) + if attr is _SENTINEL: + return False + return bool(attr == value) + return False def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: @@ -3037,6 +3057,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_areas"] = hassfunction(floor_areas) self.filters["floor_areas"] = self.globals["floor_areas"] + self.globals["floor_entities"] = hassfunction(floor_entities) + self.filters["floor_entities"] = self.globals["floor_entities"] + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 431a7a7d1f8..ef11028515a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -10,7 +10,7 @@ from functools import wraps from typing import Any from homeassistant.core import ServiceResponse -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .typing import TemplateVarsType @@ -23,11 +23,11 @@ class TraceElement: "_child_run_id", "_error", "_last_variables", - "path", "_result", - "reuse_by_child", "_timestamp", "_variables", + "path", + "reuse_by_child", ) def __init__(self, variables: TemplateVarsType, path: str) -> None: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 01c47aa8d0d..fdfefc9bff4 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -147,7 +147,7 @@ class _TranslationsCacheData: class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "cache_data", "lock") + __slots__ = ("cache_data", "hass", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6cc4584935e..7130264eb0d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,16 +6,16 @@ from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Generator from datetime import datetime, timedelta +from functools import partial import logging from random import randint from time import monotonic -from typing import Any, Generic, Protocol +from typing import Any, Generic, Protocol, TypeVar import urllib.error import aiohttp -from propcache import cached_property +from propcache.api import cached_property import requests -from typing_extensions import TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -37,11 +37,6 @@ REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True _DataT = TypeVar("_DataT", default=dict[str, Any]) -_DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", - bound="DataUpdateCoordinator[Any]", - default="DataUpdateCoordinator[dict[str, Any]]", -) class UpdateFailed(HomeAssistantError): @@ -109,7 +104,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) - self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listeners: dict[int, tuple[CALLBACK_TYPE, object | None]] = {} + self._last_listener_id: int = 0 self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -154,21 +150,26 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._listeners.pop(remove_listener) - if not self._listeners: - self._unschedule_refresh() - - self._listeners[remove_listener] = (update_callback, context) + self._last_listener_id += 1 + self._listeners[self._last_listener_id] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - return remove_listener + return partial(self.__async_remove_listener_internal, self._last_listener_id) + + @callback + def __async_remove_listener_internal(self, listener_id: int) -> None: + """Remove a listener. + + This is an internal function that is not to be overridden + in subclasses as it may change in the future. + """ + self._listeners.pop(listener_id) + if not self._listeners: + self._unschedule_refresh() + self._debounced_refresh.async_cancel() @callback def async_update_listeners(self) -> None: @@ -347,8 +348,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): only once during the first refresh. """ if self.setup_method is None: - return None - return await self.setup_method() + return + await self.setup_method() async def async_refresh(self) -> None: """Refresh data and log errors.""" @@ -365,7 +366,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._async_unsub_refresh() self._debounced_refresh.async_cancel() - if self._shutdown_requested or scheduled and self.hass.is_stopping: + if self._shutdown_requested or (scheduled and self.hass.is_stopping): return if log_timing := self.logger.isEnabledFor(logging.DEBUG): @@ -459,7 +460,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.logger.debug( "Finished fetching %s data in %.3f seconds (success: %s)", self.name, - monotonic() - start, # pylint: disable=possibly-used-before-assignment + monotonic() - start, self.last_update_success, ) if not auth_failed and self._listeners and not self.hass.is_stopping: @@ -565,7 +566,11 @@ class BaseCoordinatorEntity[ """ -class CoordinatorEntity(BaseCoordinatorEntity[_DataUpdateCoordinatorT]): +class CoordinatorEntity[ + _DataUpdateCoordinatorT: DataUpdateCoordinator[Any] = DataUpdateCoordinator[ + dict[str, Any] + ] +](BaseCoordinatorEntity[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" def __init__( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 93dc7677bba..3bc33f8374c 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,7 +25,7 @@ from awesomeversion import ( AwesomeVersionException, AwesomeVersionStrategy, ) -from propcache import cached_property +from propcache.api import cached_property import voluptuous as vol from . import generated @@ -40,7 +40,6 @@ from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .helpers.json import json_bytes, json_fragment -from .helpers.typing import UNDEFINED from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -125,9 +124,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( "components" ) -DATA_INTEGRATIONS: HassKey[dict[str, Integration | asyncio.Future[None]]] = HassKey( - "integrations" -) +DATA_INTEGRATIONS: HassKey[ + dict[str, Integration | asyncio.Future[Integration | IntegrationNotFound]] +] = HassKey("integrations") DATA_MISSING_PLATFORMS: HassKey[dict[str, bool]] = HassKey("missing_platforms") DATA_CUSTOM_COMPONENTS: HassKey[ dict[str, Integration] | asyncio.Future[dict[str, Integration]] @@ -1345,7 +1344,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio Raises IntegrationNotLoaded if the integration is not loaded. """ cache = hass.data[DATA_INTEGRATIONS] - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: return int_or_fut @@ -1355,7 +1354,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" cache = hass.data[DATA_INTEGRATIONS] - if type(int_or_fut := cache.get(domain, UNDEFINED)) is Integration: + if type(int_or_fut := cache.get(domain)) is Integration: return int_or_fut integrations_or_excs = await async_get_integrations(hass, [domain]) int_or_exc = integrations_or_excs[domain] @@ -1370,15 +1369,17 @@ async def async_get_integrations( """Get integrations.""" cache = hass.data[DATA_INTEGRATIONS] results: dict[str, Integration | Exception] = {} - needed: dict[str, asyncio.Future[None]] = {} - in_progress: dict[str, asyncio.Future[None]] = {} + needed: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} + in_progress: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} for domain in domains: - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: results[domain] = int_or_fut - elif int_or_fut is not UNDEFINED: - in_progress[domain] = cast(asyncio.Future[None], int_or_fut) + elif int_or_fut: + if TYPE_CHECKING: + assert isinstance(int_or_fut, asyncio.Future) + in_progress[domain] = int_or_fut elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: @@ -1386,14 +1387,13 @@ async def async_get_integrations( if in_progress: await asyncio.wait(in_progress.values()) - for domain in in_progress: - # When we have waited and it's UNDEFINED, it doesn't exist - # We don't cache that it doesn't exist, or else people can't fix it - # and then restart, because their config will never be valid. - if (int_or_fut := cache.get(domain, UNDEFINED)) is UNDEFINED: - results[domain] = IntegrationNotFound(domain) - else: - results[domain] = cast(Integration, int_or_fut) + # Here we retrieve the results we waited for + # instead of reading them from the cache since + # reading from the cache will have a race if + # the integration gets removed from the cache + # because it was not found. + for domain, future in in_progress.items(): + results[domain] = future.result() if not needed: return results @@ -1405,7 +1405,7 @@ async def async_get_integrations( for domain, future in needed.items(): if integration := custom.get(domain): results[domain] = cache[domain] = integration - future.set_result(None) + future.set_result(integration) for domain in results: if domain in needed: @@ -1419,18 +1419,24 @@ async def async_get_integrations( _resolve_integrations_from_root, hass, components, needed ) for domain, future in needed.items(): - int_or_exc = integrations.get(domain) - if not int_or_exc: - cache.pop(domain) - results[domain] = IntegrationNotFound(domain) - elif isinstance(int_or_exc, Exception): - cache.pop(domain) - exc = IntegrationNotFound(domain) - exc.__cause__ = int_or_exc - results[domain] = exc + if integration := integrations.get(domain): + results[domain] = cache[domain] = integration + future.set_result(integration) else: - results[domain] = cache[domain] = int_or_exc - future.set_result(None) + # We don't cache that it doesn't exist as configuration + # validation that relies on integrations being loaded + # would be unfixable. For example if a custom integration + # was temporarily removed. + # This allows restoring a missing integration to fix the + # validation error so the config validations checks do not + # block restarting. + del cache[domain] + exc = IntegrationNotFound(domain) + results[domain] = exc + # We don't use set_exception because + # we expect there will be cases where + # the future exception is never retrieved + future.set_result(exc) return results @@ -1765,8 +1771,7 @@ def async_suggest_report_issue( if not integration_domain: return "report it to the custom integration author" return ( - f"report it to the author of the '{integration_domain}' " - "custom integration" + f"report it to the author of the '{integration_domain}' custom integration" ) return f"create a bug report at {issue_tracker}" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 35a603415f9..1f1cb3c4f4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,79 +1,79 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.0.2 -aiodiscover==2.1.0 +aiodhcpwatcher==1.1.1 +aiodiscover==2.6.1 aiodns==3.2.0 -aiohasupervisor==0.2.2b5 -aiohttp-asyncmdnsresolver==0.0.1 -aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.11 +aiohasupervisor==0.3.0 +aiohttp-asyncmdnsresolver==0.1.1 +aiohttp-fast-zlib==0.2.3 +aiohttp==3.11.13 aiohttp_cors==0.7.0 -aiozoneinfo==0.2.1 +aiousbwatcher==1.1.1 +aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.0 -async-upnp-client==0.42.0 +async-interrupt==1.2.2 +async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 -attrs==24.2.0 -audioop-lts==0.2.1;python_version>='3.13' +attrs==25.1.0 +audioop-lts==0.2.1 av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.9.0 bleak==0.22.3 -bluetooth-adapters==0.20.2 -bluetooth-auto-recovery==1.4.2 -bluetooth-data-tools==1.20.0 -cached-ipaddress==0.8.0 +bluetooth-adapters==0.21.4 +bluetooth-auto-recovery==1.4.4 +bluetooth-data-tools==1.23.4 +cached-ipaddress==0.9.2 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -cryptography==44.0.0 -dbus-fast==2.24.3 -fnv-hash-fast==1.0.2 +cryptography==44.0.1 +dbus-fast==2.33.0 +fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.7.0 -hass-nabucasa==0.87.0 -hassil==2.1.0 -home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250106.0 -home-assistant-intents==2025.1.1 -httpx==0.27.2 +habluetooth==3.24.1 +hass-nabucasa==0.92.0 +hassil==2.2.3 +home-assistant-bluetooth==1.13.1 +home-assistant-frontend==20250228.0 +home-assistant-intents==2025.2.26 +httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 Pillow==11.1.0 -propcache==0.2.1 +propcache==0.3.0 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 -pyOpenSSL==24.3.0 +pyOpenSSL==25.0.0 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 -pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 -SQLAlchemy==2.0.36 -standard-aifc==3.13.0;python_version>='3.13' -standard-telnetlib==3.13.0;python_version>='3.13' +securetar==2025.2.1 +SQLAlchemy==2.0.38 +standard-aifc==3.13.0 +standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 -uv==0.5.8 +uv==0.6.1 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.137.2 +zeroconf==0.145.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -108,16 +108,16 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.7.0 +anyio==4.8.0 h11==0.14.0 -httpcore==1.0.5 +httpcore==1.0.7 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.2.1 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues @@ -128,7 +128,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.4 +pydantic==2.10.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -168,6 +168,10 @@ pysnmplib==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 +# Poetry is a build dependency. Installing it as a runtime dependency almost +# always indicates an issue with library requirements. +poetry==1000000000.0.0 + # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index b769d385a4f..c16269a2a8b 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -58,7 +58,7 @@ def benchmark[_CallableT: Callable](func: _CallableT) -> _CallableT: @benchmark -async def fire_events(hass): +async def fire_events(hass: core.HomeAssistant) -> float: """Fire a million events.""" count = 0 event_name = "benchmark_event" @@ -85,7 +85,7 @@ async def fire_events(hass): @benchmark -async def fire_events_with_filter(hass): +async def fire_events_with_filter(hass: core.HomeAssistant) -> float: """Fire a million events with a filter that rejects them.""" count = 0 event_name = "benchmark_event" @@ -117,7 +117,7 @@ async def fire_events_with_filter(hass): @benchmark -async def state_changed_helper(hass): +async def state_changed_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" @@ -141,7 +141,7 @@ async def state_changed_helper(hass): } for _ in range(10**6): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -151,7 +151,7 @@ async def state_changed_helper(hass): @benchmark -async def state_changed_event_helper(hass): +async def state_changed_event_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed event helper with 1000 entities.""" count = 0 entity_id = "light.kitchen" @@ -174,7 +174,7 @@ async def state_changed_event_helper(hass): } for _ in range(events_to_fire): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -186,7 +186,7 @@ async def state_changed_event_helper(hass): @benchmark -async def state_changed_event_filter_helper(hass): +async def state_changed_event_filter_helper(hass: core.HomeAssistant) -> float: """Run a million events through state changed event helper. With 1000 entities that all get filtered. @@ -212,7 +212,7 @@ async def state_changed_event_filter_helper(hass): } for _ in range(events_to_fire): - hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) # type: ignore[misc] start = timer() @@ -224,7 +224,7 @@ async def state_changed_event_filter_helper(hass): @benchmark -async def filtering_entity_id(hass): +async def filtering_entity_id(hass: core.HomeAssistant) -> float: """Run a 100k state changes through entity filter.""" config = { "include": { @@ -289,7 +289,7 @@ async def filtering_entity_id(hass): @benchmark -async def valid_entity_id(hass): +async def valid_entity_id(hass: core.HomeAssistant) -> float: """Run valid entity ID a million times.""" start = timer() for _ in range(10**6): @@ -298,7 +298,7 @@ async def valid_entity_id(hass): @benchmark -async def json_serialize_states(hass): +async def json_serialize_states(hass: core.HomeAssistant) -> float: """Serialize million states with websocket default encoder.""" states = [ core.State("light.kitchen", "on", {"friendly_name": "Kitchen Lights"}) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 568e8c84a30..a24568e9a6f 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -23,8 +23,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index e1ae7bc9142..1d568ec68b0 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -4,7 +4,7 @@ import argparse import asyncio import os -import homeassistant.config as config_util +from homeassistant import config as config_util from homeassistant.core import HomeAssistant # mypy: allow-untyped-calls, allow-untyped-defs diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 331389da7c6..dc4d0988b91 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -132,7 +132,13 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Keep track of domains which will load but have not yet finished loading """ setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_done_futures.update({domain: hass.loop.create_future() for domain in domains}) + setup_futures = hass.data.setdefault(DATA_SETUP, {}) + old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components + if overlap := old_domains & domains: + _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) + setup_done_futures.update( + {domain: hass.loop.create_future() for domain in domains - old_domains} + ) def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool: @@ -425,8 +431,8 @@ async def _async_setup_component( ) return False # pylint: disable-next=broad-except - except (asyncio.CancelledError, SystemExit, Exception): - _LOGGER.exception("Error during setup of component %s", domain) + except (asyncio.CancelledError, SystemExit, Exception) as exc: + _LOGGER.exception("Error during setup of component %s: %s", domain, exc) # noqa: TRY401 async_notify_setup_error(hass, domain, integration.documentation) return False finally: diff --git a/homeassistant/strings.json b/homeassistant/strings.json index fca55353aa0..29b7db7a011 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,13 +1,101 @@ { "common": { - "generic": { - "model": "Model", - "ui_managed": "Managed via UI" + "action": { + "close": "Close", + "connect": "Connect", + "disable": "Disable", + "disconnect": "Disconnect", + "enable": "Enable", + "open": "Open", + "pause": "Pause", + "reload": "Reload", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "toggle": "Toggle", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "config_flow": { + "abort": { + "already_configured_account": "Account is already configured", + "already_configured_device": "Device is already configured", + "already_configured_location": "Location is already configured", + "already_configured_service": "Service is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cloud_not_connected": "Not connected to Home Assistant Cloud.", + "no_devices_found": "No devices found on the network", + "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", + "oauth2_error": "Received invalid token data.", + "oauth2_failed": "Error while obtaining access token.", + "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth2_missing_credentials": "The integration requires application credentials.", + "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_timeout": "Timeout resolving OAuth token.", + "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", + "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." + }, + "create_entry": { + "authenticated": "Successfully authenticated" + }, + "data": { + "access_token": "Access token", + "api_key": "API key", + "api_token": "API token", + "device": "Device", + "elevation": "Elevation", + "email": "Email", + "host": "Host", + "ip": "IP address", + "language": "Language", + "latitude": "Latitude", + "llm_hass_api": "Control Home Assistant", + "location": "Location", + "longitude": "Longitude", + "mode": "Mode", + "name": "Name", + "password": "Password", + "path": "Path", + "pin": "PIN code", + "port": "Port", + "ssl": "Uses an SSL certificate", + "url": "URL", + "usb_path": "USB device path", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + }, + "description": { + "confirm_setup": "Do you want to start setup?" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_access_token": "Invalid access token", + "invalid_api_key": "Invalid API key", + "invalid_auth": "Invalid authentication", + "invalid_host": "Invalid hostname or IP address", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "title": { + "oauth2_pick_implementation": "Pick authentication method", + "reauth": "Authentication expired for {name}", + "via_hassio_addon": "{name} via Home Assistant add-on" + } }, "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" }, "extra_fields": { "above": "Above", @@ -19,30 +107,35 @@ }, "trigger_type": { "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" - }, - "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, - "action": { - "connect": "Connect", - "disconnect": "Disconnect", - "enable": "Enable", - "disable": "Disable", + "generic": { + "model": "Model", + "ui_managed": "Managed via UI" + }, + "state": { + "active": "Active", + "charging": "Charging", + "closed": "Closed", + "connected": "Connected", + "disabled": "Disabled", + "discharging": "Discharging", + "disconnected": "Disconnected", + "enabled": "Enabled", + "home": "Home", + "idle": "Idle", + "locked": "Locked", + "no": "No", + "not_home": "Away", + "off": "Off", + "on": "On", "open": "Open", - "close": "Close", - "reload": "Reload", - "restart": "Restart", - "start": "Start", - "stop": "Stop", - "pause": "Pause", - "turn_on": "Turn on", - "turn_off": "Turn off", - "toggle": "Toggle" + "paused": "Paused", + "standby": "Standby", + "unlocked": "Unlocked", + "yes": "Yes" }, "time": { "monday": "Monday", @@ -52,97 +145,6 @@ "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday" - }, - "state": { - "off": "Off", - "on": "On", - "yes": "Yes", - "no": "No", - "open": "Open", - "closed": "Closed", - "enabled": "Enabled", - "disabled": "Disabled", - "connected": "Connected", - "disconnected": "Disconnected", - "locked": "Locked", - "unlocked": "Unlocked", - "active": "Active", - "idle": "Idle", - "standby": "Standby", - "paused": "Paused", - "home": "Home", - "not_home": "Away" - }, - "config_flow": { - "title": { - "oauth2_pick_implementation": "Pick authentication method", - "reauth": "Authentication expired for {name}", - "via_hassio_addon": "{name} via Home Assistant add-on" - }, - "description": { - "confirm_setup": "Do you want to start setup?" - }, - "data": { - "device": "Device", - "name": "Name", - "email": "Email", - "username": "Username", - "password": "Password", - "host": "Host", - "ip": "IP address", - "port": "Port", - "url": "URL", - "usb_path": "USB device path", - "access_token": "Access token", - "api_key": "API key", - "api_token": "API token", - "llm_hass_api": "Control Home Assistant", - "ssl": "Uses an SSL certificate", - "verify_ssl": "Verify SSL certificate", - "elevation": "Elevation", - "longitude": "Longitude", - "latitude": "Latitude", - "location": "Location", - "pin": "PIN code", - "mode": "Mode", - "path": "Path", - "language": "Language" - }, - "create_entry": { - "authenticated": "Successfully authenticated" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_access_token": "Invalid access token", - "invalid_api_key": "Invalid API key", - "invalid_auth": "Invalid authentication", - "invalid_host": "Invalid hostname or IP address", - "unknown": "Unexpected error", - "timeout_connect": "Timeout establishing connection" - }, - "abort": { - "single_instance_allowed": "Already configured. Only a single configuration possible.", - "already_configured_account": "Account is already configured", - "already_configured_device": "Device is already configured", - "already_configured_location": "Location is already configured", - "already_configured_service": "Service is already configured", - "already_in_progress": "Configuration flow is already in progress", - "no_devices_found": "No devices found on the network", - "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.", - "oauth2_error": "Received invalid token data.", - "oauth2_timeout": "Timeout resolving OAuth token.", - "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", - "oauth2_missing_credentials": "The integration requires application credentials.", - "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", - "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "oauth2_user_rejected_authorize": "Account linking rejected: {error}", - "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", - "oauth2_failed": "Error while obtaining access token.", - "reauth_successful": "Re-authentication was successful", - "reconfigure_successful": "Re-configuration was successful", - "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", - "cloud_not_connected": "Not connected to Home Assistant Cloud." - } } } } diff --git a/homeassistant/util/event_type.py b/homeassistant/util/event_type.py index 509a35d33ae..e0083057272 100644 --- a/homeassistant/util/event_type.py +++ b/homeassistant/util/event_type.py @@ -6,14 +6,10 @@ Custom for type checking. See stub file. from __future__ import annotations from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar - -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +from typing import Any -class EventType(str, Generic[_DataT]): +class EventType[_DataT: Mapping[str, Any] = Mapping[str, Any]](str): """Custom type for Event.event_type. At runtime this is a generic subclass of str. diff --git a/homeassistant/util/event_type.pyi b/homeassistant/util/event_type.pyi index 4285e54e8c9..f9cb140440f 100644 --- a/homeassistant/util/event_type.pyi +++ b/homeassistant/util/event_type.pyi @@ -2,15 +2,15 @@ # ruff: noqa: PYI021 # Allow docstrings from collections.abc import Mapping -from typing import Any, Generic - -from typing_extensions import TypeVar +from typing import Any, Generic, TypeVar __all__ = [ "EventType", ] -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) +_DataT = TypeVar( # needs to be invariant + "_DataT", bound=Mapping[str, Any], default=Mapping[str, Any] +) class EventType(Generic[_DataT]): """Custom type for Event.event_type. At runtime delegated to str. diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 968567ae0c9..a935d44d585 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -46,7 +46,7 @@ def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayT """Parse JSON data and ensure result is a list.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") @@ -55,7 +55,7 @@ def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjec """Parse JSON data and ensure result is a dictionary.""" value: JsonValueType = json_loads(obj) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}") @@ -95,7 +95,7 @@ def load_json_array( default = [] value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # noqa: E721 + if type(value) is list: return value _LOGGER.exception( "Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename @@ -115,7 +115,7 @@ def load_json_object( default = {} value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # noqa: E721 + if type(value) is dict: return value _LOGGER.exception( "Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index d7593013046..bebd399a5cd 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -93,8 +93,9 @@ def raise_for_blocking_call( return if found_frame is None: - raise RuntimeError( # noqa: TRY200 - f"Caught blocking call to {func.__name__} with args {mapped_args.get("args")} " + raise RuntimeError( # noqa: B904 + f"Caught blocking call to {func.__name__} " + f"with args {mapped_args.get('args')} " f"in {offender_filename}, line {offender_lineno}: {offender_line} " "inside the event loop; " "This is causing stability issues. " diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 08a2c2a3967..70d7dc80505 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -98,8 +98,7 @@ def is_host_valid(host: str) -> bool: return False if re.match(r"^[0-9\.]+$", host): # reject invalid IPv4 return False - if host.endswith("."): # dot at the end is correct - host = host[:-1] + host = host.removesuffix(".") allowed = re.compile(r"(?!-)[A-Z\d\-]{1,63}(? float: @@ -105,6 +107,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: to_ratio / (val / from_ratio) return lambda val: (val / from_ratio) * to_ratio @classmethod @@ -129,6 +133,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: None if val is None else to_ratio / (val / from_ratio) return lambda val: None if val is None else (val / from_ratio) * to_ratio @classmethod @@ -138,6 +144,12 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: + """Return true if one unit is an inverse but not the other.""" + return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES) + class DataRateConverter(BaseUnitConverter): """Utility to convert data rate values.""" @@ -249,11 +261,15 @@ class ElectricPotentialConverter(BaseUnitConverter): UnitOfElectricPotential.VOLT: 1, UnitOfElectricPotential.MILLIVOLT: 1e3, UnitOfElectricPotential.MICROVOLT: 1e6, + UnitOfElectricPotential.KILOVOLT: 1 / 1e3, + UnitOfElectricPotential.MEGAVOLT: 1 / 1e6, } VALID_UNITS = { UnitOfElectricPotential.VOLT, UnitOfElectricPotential.MILLIVOLT, UnitOfElectricPotential.MICROVOLT, + UnitOfElectricPotential.KILOVOLT, + UnitOfElectricPotential.MEGAVOLT, } @@ -280,6 +296,22 @@ class EnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfEnergy) +class EnergyDistanceConverter(BaseUnitConverter): + """Utility to convert vehicle energy consumption values.""" + + UNIT_CLASS = "energy_distance" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM: 1, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR: 100 * _KM_TO_M / _MILE_TO_M, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR: 100, + } + _UNIT_INVERSES: set[str] = { + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + } + VALID_UNITS = set(UnitOfEnergyDistance) + + class InformationConverter(BaseUnitConverter): """Utility to convert information values.""" diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index cf90b223cb6..3b1f5c4cc0a 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -16,15 +16,15 @@ from .objects import Input __all__ = [ "SECRET_YAML", "Input", - "dump", - "save_yaml", "Secrets", + "UndefinedSubstitution", "YamlTypeError", + "dump", + "extract_inputs", "load_yaml", "load_yaml_dict", - "secret_yaml", "parse_yaml", - "UndefinedSubstitution", - "extract_inputs", + "save_yaml", + "secret_yaml", "substitute", ] diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 39d38a8f47d..3911d62040b 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -22,7 +22,7 @@ except ImportError: SafeLoader as FastestAvailableSafeLoader, ) -from propcache import cached_property +from propcache.api import cached_property from homeassistant.exceptions import HomeAssistantError diff --git a/mypy.ini b/mypy.ini index 55fd0b3cd65..c69401b8605 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,7 +3,7 @@ # To update, run python3 -m script.hassfest -p mypy_config [mypy] -python_version = 3.12 +python_version = 3.13 platform = linux plugins = pydantic.mypy, pydantic.v1.mypy show_error_codes = true @@ -785,6 +785,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.azure_storage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.backup.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -945,6 +955,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.brother.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1926,6 +1946,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_drive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1996,6 +2026,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.habitica.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.hardkernel.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2016,6 +2056,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.heos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.here_travel_time.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2056,6 +2106,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.home_connect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homeassistant.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2116,6 +2176,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homekit.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2366,6 +2436,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.incomfort.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2666,6 +2746,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.letpot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2806,6 +2896,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lovelace.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.luftdaten.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2866,6 +2966,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mcp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.mcp_server.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3286,6 +3396,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onedrive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3456,6 +3576,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.person.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.pi_hole.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3586,6 +3716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pyload.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.python_script.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3596,6 +3736,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.qbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.qnap_qsw.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3696,6 +3846,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remember_the_milk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.remote.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4006,6 +4166,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sensorpush_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sensoterra.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4879,6 +5049,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vodafone_station.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 09fe61b68c6..cc7b33d9946 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -140,7 +140,7 @@ class HassEnforceClassModule(BaseChecker): for ancestor in top_level_ancestors: if ancestor.name in _BASE_ENTITY_MODULES and not any( - anc.name in _MODULE_CLASSES for anc in ancestors + parent.name in _MODULE_CLASSES for parent in ancestors ): self.add_message( "hass-enforce-class-module", diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a837650f3b5..a4590207294 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -55,10 +55,14 @@ class TypeHintMatch: """Confirm if function should be checked.""" return ( self.function_name == node.name - or self.has_async_counterpart - and node.name == f"async_{self.function_name}" - or self.function_name.endswith("*") - and node.name.startswith(self.function_name[:-1]) + or ( + self.has_async_counterpart + and node.name == f"async_{self.function_name}" + ) + or ( + self.function_name.endswith("*") + and node.name.startswith(self.function_name[:-1]) + ) ) @@ -102,7 +106,8 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "aiohttp_client": "ClientSessionGenerator", "aiohttp_server": "Callable[[], TestServer]", "area_registry": "AreaRegistry", - "async_test_recorder": "RecorderInstanceGenerator", + "async_test_recorder": "RecorderInstanceContextManager", + "async_setup_recorder_instance": "RecorderInstanceGenerator", "caplog": "pytest.LogCaptureFixture", "capsys": "pytest.CaptureFixture[str]", "current_request_with_host": "None", @@ -247,7 +252,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { arg_types={ 0: "HomeAssistant", 1: "ConfigEntry", - 2: "AddEntitiesCallback", + 2: "AddConfigEntryEntitiesCallback", }, return_type=None, ), @@ -1017,6 +1022,34 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { return_type=None, has_async_counterpart=True, ), + TypeHintMatch( + function_name="async_handle_async_webrtc_offer", + arg_types={ + 1: "str", + 2: "str", + 3: "WebRTCSendMessage", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_on_webrtc_candidate", + arg_types={ + 1: "str", + 2: "RTCIceCandidateInit", + }, + return_type=None, + ), + TypeHintMatch( + function_name="close_webrtc_session", + arg_types={ + 1: "str", + }, + return_type=None, + ), + TypeHintMatch( + function_name="_async_get_webrtc_client_configuration", + return_type="WebRTCClientConfiguration", + ), ], ), ], @@ -1377,6 +1410,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "entity": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), + ], "fan": [ ClassTypeHintMatch( base_class="Entity", @@ -2970,8 +3013,8 @@ def _is_valid_type( isinstance(node, nodes.Subscript) and isinstance(node.value, nodes.Name) and node.value.name in _KNOWN_GENERIC_TYPES - or isinstance(node, nodes.Name) - and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) + ) or ( + isinstance(node, nodes.Name) and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) ): return True diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 194f99ae700..0d6582535f7 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -21,7 +21,7 @@ class ObsoleteImportMatch: _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { "functools": [ ObsoleteImportMatch( - reason="replaced by propcache.cached_property", + reason="replaced by propcache.api.cached_property", constant=re.compile(r"^cached_property$"), ), ], @@ -33,7 +33,7 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { ], "homeassistant.backports.functools": [ ObsoleteImportMatch( - reason="replaced by propcache.cached_property", + reason="replaced by propcache.api.cached_property", constant=re.compile(r"^cached_property$"), ), ], @@ -129,6 +129,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^IMPERIAL_SYSTEM$"), ), ], + "propcache": [ + ObsoleteImportMatch( + reason="importing from propcache.api recommended", + constant=re.compile(r"^(under_)?cached_property$"), + ), + ], } _IGNORE_ROOT_IMPORT = ( @@ -268,9 +274,8 @@ class HassImportsFormatChecker(BaseChecker): self, current_package: str, node: nodes.ImportFrom ) -> None: """Check for improper 'from ._ import _' invocations.""" - if ( - node.level <= 1 - or not current_package.startswith("homeassistant.components.") + if node.level <= 1 or ( + not current_package.startswith("homeassistant.components.") and not current_package.startswith("tests.components.") ): return diff --git a/pyproject.toml b/pyproject.toml index 3889dadff74..6a75ffa002b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.4.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -18,72 +18,71 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.12.0" +requires-python = ">=3.13.0" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b5", - "aiohttp==3.11.11", + "aiohasupervisor==0.3.0", + "aiohttp==3.11.13", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", - "aiozoneinfo==0.2.1", + "aiohttp-fast-zlib==0.2.3", + "aiohttp-asyncmdnsresolver==0.1.1", + "aiozoneinfo==0.2.3", "astral==2.2", - "async-interrupt==1.2.0", - "attrs==24.2.0", + "async-interrupt==1.2.2", + "attrs==25.1.0", "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1;python_version>='3.13'", + "audioop-lts==0.2.1", "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.0.2", + "fnv-hash-fast==1.2.6", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.87.0", + "hass-nabucasa==0.92.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.27.2", - "home-assistant-bluetooth==1.13.0", + "httpx==0.28.1", + "home-assistant-bluetooth==1.13.1", "ifaddr==0.2.0", "Jinja2==3.1.5", "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.0", + "cryptography==44.0.1", "Pillow==11.1.0", - "propcache==0.2.1", - "pyOpenSSL==24.3.0", - "orjson==3.10.12", + "propcache==0.3.0", + "pyOpenSSL==25.0.0", + "orjson==3.10.15", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2024.11.0", - "SQLAlchemy==2.0.36", - "standard-aifc==3.13.0;python_version>='3.13'", - "standard-telnetlib==3.13.0;python_version>='3.13'", + "securetar==2025.2.1", + "SQLAlchemy==2.0.38", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.0.2", + "ulid-transform==1.2.1", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.8", + "uv==0.6.1", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.137.2" + "zeroconf==0.145.1" ] [project.urls] @@ -104,7 +103,7 @@ include-package-data = true include = ["homeassistant*"] [tool.pylint.MAIN] -py-version = "3.12" +py-version = "3.13" # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 @@ -701,17 +700,12 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.8.0" +required-version = ">=0.9.1" [tool.ruff.lint] select = [ "A001", # Variable {name} is shadowing a Python builtin - "ASYNC210", # Async functions should not call blocking HTTP methods - "ASYNC220", # Async functions should not create subprocesses with blocking methods - "ASYNC221", # Async functions should not run processes with blocking methods - "ASYNC222", # Async functions should not wait on processes with blocking methods - "ASYNC230", # Async functions should not open files with blocking methods like open - "ASYNC251", # Async functions should not call time.sleep + "ASYNC", # flake8-async "B002", # Python does not support the unary prefix increment "B005", # Using .strip() with multi-character strings is misleading "B007", # Loop control variable {name} not used within loop body @@ -720,8 +714,10 @@ select = [ "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} + "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B035", # Dictionary comprehension uses static key "B904", # Use raise from to specify exception cause "B905", # zip() without an explicit strict= parameter "BLE", @@ -755,12 +751,27 @@ select = [ "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs + "RUF008", # Do not use mutable default values for dataclass attributes "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional + "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer "RUF017", # Avoid quadratic list summation "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access - # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up + "RUF020", # {never_like} | T is equivalent to T + "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear + "RUF022", # Sort __all__ + "RUF023", # Sort __slots__ + "RUF024", # Do not pass mutable objects as values to dict.fromkeys + "RUF026", # default_factory is a positional-only argument to defaultdict + "RUF030", # print() call in assert statement is likely unintentional + "RUF032", # Decimal() called with float literal argument + "RUF033", # __post_init__ method with argument defaults + "RUF034", # Useless if-else condition + "RUF100", # Unused `noqa` directive + "RUF101", # noqa directives that use redirected rule codes + "RUF200", # Failed to parse pyproject.toml: {message} "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file @@ -794,6 +805,8 @@ select = [ ] ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line @@ -839,7 +852,6 @@ ignore = [ "Q", "COM812", "COM819", - "ISC001", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605" @@ -897,7 +909,15 @@ voluptuous = "vol" "homeassistant.helpers.floor_registry" = "fr" "homeassistant.helpers.issue_registry" = "ir" "homeassistant.helpers.label_registry" = "lr" +"homeassistant.util.color" = "color_util" "homeassistant.util.dt" = "dt_util" +"homeassistant.util.json" = "json_util" +"homeassistant.util.location" = "location_util" +"homeassistant.util.logging" = "logging_util" +"homeassistant.util.network" = "network_util" +"homeassistant.util.ulid" = "ulid_util" +"homeassistant.util.uuid" = "uuid_util" +"homeassistant.util.yaml" = "yaml_util" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false @@ -936,4 +956,4 @@ split-on-trailing-comma = false max-complexity = 25 [tool.ruff.lint.pydocstyle] -property-decorators = ["propcache.cached_property"] +property-decorators = ["propcache.api.cached_property"] diff --git a/requirements.txt b/requirements.txt index 9aaef2a6b79..76c5059e29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,51 +4,51 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b5 -aiohttp==3.11.11 +aiohasupervisor==0.3.0 +aiohttp==3.11.13 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 -aiozoneinfo==0.2.1 +aiohttp-fast-zlib==0.2.3 +aiohttp-asyncmdnsresolver==0.1.1 +aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.0 -attrs==24.2.0 +async-interrupt==1.2.2 +attrs==25.1.0 atomicwrites-homeassistant==1.4.1 -audioop-lts==0.2.1;python_version>='3.13' +audioop-lts==0.2.1 awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.0.2 -hass-nabucasa==0.87.0 -httpx==0.27.2 -home-assistant-bluetooth==1.13.0 +fnv-hash-fast==1.2.6 +hass-nabucasa==0.92.0 +httpx==0.28.1 +home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 -cryptography==44.0.0 +cryptography==44.0.1 Pillow==11.1.0 -propcache==0.2.1 -pyOpenSSL==24.3.0 -orjson==3.10.12 +propcache==0.3.0 +pyOpenSSL==25.0.0 +orjson==3.10.15 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2024.11.0 -SQLAlchemy==2.0.36 -standard-aifc==3.13.0;python_version>='3.13' -standard-telnetlib==3.13.0;python_version>='3.13' +securetar==2025.2.1 +SQLAlchemy==2.0.38 +standard-aifc==3.13.0 +standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.0.2 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 -uv==0.5.8 +uv==0.6.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.137.2 +zeroconf==0.145.1 diff --git a/requirements_all.txt b/requirements_all.txt index 34e7a267c7d..45484a6f2d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.28 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 @@ -57,7 +57,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.3.2 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.39.1 +PyViCare==2.43.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -109,14 +109,14 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==7.4 +RestrictedPython==8.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.38 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 @@ -128,10 +128,10 @@ TravisPy==0.3.5 TwitterAPI==2.7.12 # homeassistant.components.onvif -WSDiscovery==2.0.0 +WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 @@ -140,7 +140,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 @@ -173,16 +173,16 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.13 +aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -213,13 +213,13 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.1.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.0 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -258,22 +258,25 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.10 +aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.3.0 + +# homeassistant.components.home_connect +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -285,7 +288,7 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.0 +aiolifx-themes==0.6.4 # homeassistant.components.lifx aiolifx==1.1.2 @@ -312,16 +315,16 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.nut -aionut==4.3.3 +aionut==4.3.4 # homeassistant.components.oncue -aiooncue==0.3.7 +aiooncue==0.3.9 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 @@ -347,7 +350,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -368,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -380,10 +383,10 @@ aioslimproto==3.0.0 aiosolaredge==0.2.0 # homeassistant.components.steamist -aiosteamist==1.0.0 +aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 @@ -401,7 +404,10 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==81 +aiounifi==83 + +# homeassistant.components.usb +aiousbwatcher==1.1.1 # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -415,17 +421,20 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.3.1 + # homeassistant.components.webostv -aiowebostv==0.4.2 +aiowebostv==0.7.1 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 @@ -455,7 +464,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -467,10 +476,10 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.31.2 +anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.7.0 +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -488,10 +497,10 @@ apsystems-ez1==2.4.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.1 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 @@ -505,13 +514,13 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.42.0 +async-upnp-client==0.43.0 # homeassistant.components.arve asyncarve==0.1.1 # homeassistant.components.keyboard_remote -asyncinotify==4.0.2 +asyncinotify==4.2.0 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -548,7 +557,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.4 +ayla-iot-unofficial==1.4.5 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -562,6 +571,9 @@ azure-kusto-ingest==4.5.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 @@ -591,10 +603,10 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.0.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.9.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -619,16 +631,16 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.2 +bluetooth-auto-recovery==1.4.4 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 @@ -644,7 +656,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.2 # homeassistant.components.broadlink broadlink==0.19.0 @@ -662,7 +674,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.12.4 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -674,7 +686,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.0 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 @@ -710,7 +722,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -732,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 @@ -744,7 +756,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -755,13 +767,13 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 # homeassistant.components.devialet -devialet==1.4.5 +devialet==1.5.7 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 @@ -779,7 +791,7 @@ directv==0.4.0 discogs-client==2.3.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 @@ -815,10 +827,10 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.3 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 @@ -830,7 +842,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -857,7 +869,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 @@ -887,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -924,17 +936,17 @@ fixerio==1.0.0a0 fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet -flexit_bacnet==2.2.1 +flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.0 +flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 @@ -946,7 +958,7 @@ forecast-solar==4.0.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.free_mobile freesms==0.2.0 @@ -959,7 +971,7 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth gardena-bluetooth==1.5.0 @@ -993,7 +1005,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 @@ -1018,7 +1030,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.23.0 +google-cloud-pubsub==2.28.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 @@ -1027,10 +1039,10 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.8.2 +google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.0.0 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -1046,10 +1058,10 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.43.0 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.1 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -1094,19 +1106,19 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.2 +habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.7.0 +habluetooth==3.24.1 # homeassistant.components.cloud -hass-nabucasa==0.87.0 +hass-nabucasa==0.92.0 # homeassistant.components.splunk hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1137,19 +1149,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 - -# homeassistant.components.home_connect -homeconnect==0.8.0 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud -homematicip==1.1.5 +homematicip==1.1.7 # homeassistant.components.horizon horimote==0.4.1 @@ -1199,16 +1208,16 @@ ifaddr==0.2.0 iglo==1.2.7 # homeassistant.components.igloohome -igloohome-api==0.0.6 +igloohome-api==0.1.0 # homeassistant.components.ihc ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.7 +imgw_pib==1.0.9 # homeassistant.components.incomfort -incomfort-client==0.6.4 +incomfort-client==0.6.7 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1217,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.5.8 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1225,6 +1234,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 @@ -1235,7 +1247,7 @@ iperf3==0.1.11 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 @@ -1244,7 +1256,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest @@ -1269,7 +1281,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 @@ -1278,7 +1290,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.3 +lacrosse-view==1.1.1 # homeassistant.components.eufy lakeside==0.13 @@ -1287,7 +1299,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1296,11 +1308,14 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.1 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.4.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 @@ -1361,6 +1376,7 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp # homeassistant.components.mcp_server mcp==1.1.2 @@ -1398,7 +1414,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.2 +millheater==0.12.3 # homeassistant.components.minio minio==7.1.12 @@ -1416,7 +1432,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1431,7 +1447,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 @@ -1443,7 +1459,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.6.0 +myuplink==0.7.0 # homeassistant.components.nad nad-receiver==0.3.0 @@ -1467,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.8 +nexia==2.0.9 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1479,19 +1495,19 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.4.10 # homeassistant.components.nibe_heatpump nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nilu niluclient==0.1.2 # homeassistant.components.noaa_tides -noaa-coops==0.1.9 +noaa-coops==0.4.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1516,7 +1532,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.2.1 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1537,10 +1553,10 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.3 +ohme==1.3.2 # homeassistant.components.ollama -ollama==0.4.5 +ollama==0.4.7 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1548,8 +1564,11 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.12 + # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.5 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1558,7 +1577,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.35.7 +openai==1.61.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1582,7 +1601,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.7 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1603,7 +1622,7 @@ ovoenergy==2.0.0 p1monitor==3.1.0 # homeassistant.components.mqtt -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 # homeassistant.components.panasonic_bluray panacotta==0.2 @@ -1615,10 +1634,10 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.pencom pencompy==0.0.3 @@ -1650,7 +1669,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.4 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1662,7 +1681,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 @@ -1736,7 +1755,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.0 +py-synologydsm-api==2.7.0 # homeassistant.components.atome pyAtome==0.1.1 @@ -1760,7 +1779,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.0 +pyHomee==1.2.7 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1812,7 +1831,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.0.2 +pybalboa==1.1.3 # homeassistant.components.bbox pybbox==0.0.5-alpha @@ -1824,7 +1843,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 @@ -1887,7 +1906,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.12.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1899,7 +1918,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.23 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 @@ -1920,7 +1939,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.0 +pyenphase==1.25.1 # homeassistant.components.envisalink pyenvisalink==4.7 @@ -1938,13 +1957,13 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 @@ -1959,7 +1978,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.12 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 @@ -1977,10 +1996,10 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.8.0 +pyheos==1.0.2 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -2001,7 +2020,7 @@ pyinsteon==1.6.3 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==3.0.8 +pyipma==3.0.9 # homeassistant.components.ipp pyipp==0.17.0 @@ -2013,7 +2032,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 @@ -2058,7 +2077,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 @@ -2076,7 +2095,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.23.0 @@ -2160,7 +2179,7 @@ pyombi==0.1.10 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -2180,19 +2199,22 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.15.5 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.15 +pypalazzetti==0.1.19 # homeassistant.components.elv pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.1 +pypck==0.8.5 + +# homeassistant.components.pglab +pypglab==0.0.3 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -2207,7 +2229,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.9 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 @@ -2222,7 +2244,7 @@ pyqvrpro==0.52 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 @@ -2234,7 +2256,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -2243,7 +2265,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.8 +pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 @@ -2270,7 +2292,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 @@ -2282,25 +2304,25 @@ pysignalclirestapi==0.3.24 pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==2.4.1 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 + +# homeassistant.components.smhi +pysmhi==1.0.0 # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.4 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2318,13 +2340,13 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==2.0.1 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -2336,7 +2358,7 @@ pytautulli==23.1.1 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 @@ -2374,11 +2396,14 @@ python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 +# homeassistant.components.google_drive +python-google-drive-api==0.1.0 + # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.1 +python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.1 +python-homewizard-energy==v8.3.2 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2393,10 +2418,10 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.1 +python-linkplay==0.1.3 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2421,13 +2446,13 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.5.0 +python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2436,19 +2461,22 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 + +# homeassistant.components.snoo +python-snoo==0.6.0 # homeassistant.components.songpal python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.5 +python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 @@ -2466,7 +2494,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline -pytouchline==0.7 +pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl pytouchlinesl==0.3.0 @@ -2487,9 +2515,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2503,7 +2528,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.15 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 @@ -2553,6 +2578,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.3.0 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2590,7 +2618,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.6 +reolink-aio==0.12.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2617,7 +2645,7 @@ rokuecp==0.19.3 romy==0.0.10 # homeassistant.components.roomba -roombapy==1.8.1 +roombapy==1.9.0 # homeassistant.components.roon roonapi==0.1.6 @@ -2659,14 +2687,14 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.2.1 # homeassistant.components.sendgrid sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.4 +sense-energy==0.13.5 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -2674,14 +2702,20 @@ sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 +# homeassistant.components.sensorpush_cloud +sensorpush-api==2.1.1 + # homeassistant.components.sensorpush sensorpush-ble==1.7.1 +# homeassistant.components.sensorpush_cloud +sensorpush-ha==1.3.2 + # homeassistant.components.sensoterra sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 @@ -2714,7 +2748,7 @@ sisyphus-control==3.1.4 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.xmpp slixmpp==1.8.5 @@ -2722,14 +2756,11 @@ slixmpp==1.8.5 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 -# homeassistant.components.smhi -smhi-pkg==1.0.18 - # homeassistant.components.snapcast snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.6 +soco==0.30.9 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2777,7 +2808,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.1 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 @@ -2800,7 +2831,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2841,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.2 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2850,7 +2881,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.6.10 # homeassistant.components.tessie tessie-api==0.1.1 @@ -2859,16 +2890,16 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.11.0 # homeassistant.components.thingspeak thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tikteck tikteck==0.4 @@ -2880,16 +2911,16 @@ tilt-ble==0.2.3 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.1.2 +todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2922,13 +2953,13 @@ twilio==6.32.0 twitchAPI==4.2.1 # homeassistant.components.monarch_money -typedmonarchmoney==0.3.1 +typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.4.1 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2943,10 +2974,10 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.25 +universal-silabs-flasher==0.0.29 # homeassistant.components.upb -upb-lib==0.5.9 +upb-lib==0.6.0 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2969,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.3 +velbus-aio==2025.1.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2978,7 +3009,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.2 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 @@ -3003,7 +3034,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 @@ -3015,7 +3046,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 @@ -3027,10 +3058,10 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.12.22 +weheat==2025.2.22 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 @@ -3045,7 +3076,7 @@ wirelesstagpy==0.8.1 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.15 +wolf-comm==0.0.19 # homeassistant.components.wyoming wyoming==1.5.4 @@ -3057,7 +3088,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.6.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -3078,29 +3109,29 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.6 +yalexs-ble==2.5.7 # homeassistant.components.august # homeassistant.components.yale yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.7 +yolink-api==0.4.8 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.02.19 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3112,13 +3143,13 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.137.2 +zeroconf==0.145.1 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.51 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 @@ -3130,7 +3161,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index b3a50bd96a6..0a7a3bb18e5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,49 +7,48 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.6 -coverage==7.6.8 +astroid==3.3.8 +coverage==7.6.10 freezegun==1.5.1 -license-expression==30.4.0 +license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.15.0a1 +mypy-dev==1.16.0a3 pre-commit==4.0.0 -pydantic==2.10.4 -pylint==3.3.2 -pylint-per-file-ignores==1.3.2 -pipdeptree==2.23.4 -pytest-asyncio==0.24.0 -pytest-aiohttp==1.0.5 +pydantic==2.10.6 +pylint==3.3.4 +pylint-per-file-ignores==1.4.0 +pipdeptree==2.25.0 +pytest-asyncio==0.25.3 +pytest-aiohttp==1.1.0 pytest-cov==6.0.0 -pytest-freezer==0.4.8 -pytest-github-actions-annotate-failures==0.2.0 +pytest-freezer==0.4.9 +pytest-github-actions-annotate-failures==0.3.0 pytest-socket==0.7.0 pytest-sugar==1.0.0 pytest-timeout==2.3.1 pytest-unordered==0.6.1 -pytest-picked==0.5.0 +pytest-picked==0.5.1 pytest-xdist==3.6.1 pytest==8.3.4 requests-mock==1.12.1 -respx==0.21.1 -syrupy==4.8.0 -tqdm==4.66.5 +respx==0.22.0 +syrupy==4.8.1 +tqdm==4.67.1 types-aiofiles==24.1.0.20241221 types-atomicwrites==1.4.5.1 types-croniter==5.0.1.20241205 -types-beautifulsoup4==4.12.0.20241020 +types-beautifulsoup4==4.12.0.20250204 types-caldav==1.3.0.20241107 types-chardet==0.1.5 -types-decorator==5.1.8.20240310 -types-paho-mqtt==1.6.0.20240321 +types-decorator==5.1.8.20250121 types-pexpect==4.9.0.20241208 types-pillow==10.2.0.20240822 types-protobuf==5.29.1.20241207 types-psutil==6.1.0.20241221 -types-pyserial==3.5.0.20241221 +types-pyserial==3.5.0.20250130 types-python-dateutil==2.9.0.20241206 types-python-slugify==8.0.2.20240310 -types-pytz==2024.2.0.20241221 +types-pytz==2025.1.0.20250204 types-PyYAML==6.0.12.20241230 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59cd9320e44..d2be4b80bfb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.28 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 PyChromecast==14.0.5 # homeassistant.components.flick_electric -PyFlick==1.1.2 +PyFlick==1.1.3 # homeassistant.components.flume PyFlume==0.6.5 @@ -54,7 +54,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.3.2 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.1 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.39.1 +PyViCare==2.43.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -103,23 +103,23 @@ PyXiaomiGateway==0.14.3 RachioPy==1.1.0 # homeassistant.components.python_script -RestrictedPython==7.4 +RestrictedPython==8.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.38 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 # homeassistant.components.onvif -WSDiscovery==2.0.0 +WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 @@ -128,7 +128,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 @@ -161,16 +161,16 @@ aio-geojson-usgs-earthquakes==0.3 aio-georss-gdacs==0.10 # homeassistant.components.acaia -aioacaia==0.1.13 +aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.7 +aioairzone==0.9.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.12.0 +aioautomower==2025.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -201,13 +201,13 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.0.2 +aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.1.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.0 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 @@ -243,22 +243,25 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.10 +aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.3.0 + +# homeassistant.components.home_connect +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap -aioimaplib==1.1.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -267,7 +270,7 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.6.0 +aiolifx-themes==0.6.4 # homeassistant.components.lifx aiolifx==1.1.2 @@ -294,16 +297,16 @@ aionanoleaf==0.2.1 aionotion==2024.03.0 # homeassistant.components.nut -aionut==4.3.3 +aionut==4.3.4 # homeassistant.components.oncue -aiooncue==0.3.7 +aiooncue==0.3.9 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 # homeassistant.components.nmap_tracker -aiooui==0.1.7 +aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 @@ -329,7 +332,7 @@ aiopyarr==23.4.0 aioqsw==0.4.1 # homeassistant.components.rainforest_raven -aioraven==0.7.0 +aioraven==0.7.1 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 @@ -350,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.2.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -362,10 +365,10 @@ aioslimproto==3.0.0 aiosolaredge==0.2.0 # homeassistant.components.steamist -aiosteamist==1.0.0 +aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 @@ -383,7 +386,10 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==81 +aiounifi==83 + +# homeassistant.components.usb +aiousbwatcher==1.1.1 # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -397,17 +403,20 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.3.1 + # homeassistant.components.webostv -aiowebostv==0.4.2 +aiowebostv==0.7.1 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 @@ -431,7 +440,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anova anova-wifi==0.17.0 @@ -440,10 +449,10 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.31.2 +anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.7.0 +anyio==4.8.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 @@ -458,10 +467,10 @@ aprslib==0.7.2 apsystems-ez1==2.4.0 # homeassistant.components.aranet -aranet4==2.5.0 +aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.1 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -469,7 +478,7 @@ arcam-fmj==1.5.2 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.42.0 +async-upnp-client==0.43.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -497,7 +506,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.4 +ayla-iot-unofficial==1.4.5 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -508,6 +517,9 @@ azure-kusto-data[aio]==4.5.1 # homeassistant.components.azure_data_explorer azure-kusto-ingest==4.5.1 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 @@ -522,10 +534,10 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.0.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.6.0 +bleak-retry-connector==3.9.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -543,16 +555,16 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.20.2 +bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.2 +bluetooth-auto-recovery==1.4.4 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.20.0 +bluetooth-data-tools==1.23.4 # homeassistant.components.bond bond-async==0.2.1 @@ -564,7 +576,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.2 # homeassistant.components.broadlink broadlink==0.19.0 @@ -579,13 +591,13 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.12.4 # homeassistant.components.buienradar buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.0 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 @@ -606,7 +618,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.11.2 +cookidoo-api==0.12.2 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -628,13 +640,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.24.3 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==10.1.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -645,13 +657,13 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.1.1 +demetriek==1.2.0 # homeassistant.components.denonavr denonavr==1.0.1 # homeassistant.components.devialet -devialet==1.4.5 +devialet==1.5.7 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.3 @@ -666,7 +678,7 @@ dio-chacon-wifi-api==1.2.1 directv==0.4.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 @@ -693,10 +705,10 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.3 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 @@ -705,7 +717,7 @@ elevenlabs==1.9.0 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.10 +elkm1-lib==2.2.11 # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -726,7 +738,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 @@ -753,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 @@ -783,17 +795,17 @@ fivem-api==0.1.2 fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet -flexit_bacnet==2.2.1 +flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.0 +flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.0.2 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 @@ -802,7 +814,7 @@ foobot_async==1.0.0 forecast-solar==4.0.0 # homeassistant.components.freebox -freebox-api==1.2.1 +freebox-api==1.2.2 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor @@ -812,7 +824,7 @@ fritzconnection[qr]==1.14.0 fyta_cli==0.7.0 # homeassistant.components.google_translate -gTTS==2.2.4 +gTTS==2.5.3 # homeassistant.components.gardena_bluetooth gardena-bluetooth==1.5.0 @@ -846,7 +858,7 @@ georss-qld-bushfire-alert-client==0.8 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.9.4 +getmac==0.9.5 # homeassistant.components.gios gios==5.0.0 @@ -868,7 +880,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.23.0 +google-cloud-pubsub==2.28.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 @@ -877,10 +889,10 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.8.2 +google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.0.0 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -896,10 +908,10 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.40.0 +govee-ble==0.43.0 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.1 # homeassistant.components.gpsd gps3==0.33.3 @@ -935,16 +947,16 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.2 +habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.7.0 +habluetooth==3.24.1 # homeassistant.components.cloud -hass-nabucasa==0.87.0 +hass-nabucasa==0.92.0 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -966,19 +978,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250106.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 - -# homeassistant.components.home_connect -homeconnect==0.8.0 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud -homematicip==1.1.5 +homematicip==1.1.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1016,13 +1025,13 @@ idasen-ha==2.6.3 ifaddr==0.2.0 # homeassistant.components.igloohome -igloohome-api==0.0.6 +igloohome-api==0.1.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.7 +imgw_pib==1.0.9 # homeassistant.components.incomfort -incomfort-client==0.6.4 +incomfort-client==0.6.7 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1031,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.5.8 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 @@ -1039,6 +1048,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 @@ -1046,7 +1058,7 @@ iottycloud==0.3.0 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 @@ -1055,7 +1067,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest @@ -1071,7 +1083,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2024.12.26.233449 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 @@ -1080,13 +1092,13 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.3 +lacrosse-view==1.1.1 # homeassistant.components.laundrify laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.2 +lcn-frontend==0.2.3 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 @@ -1095,11 +1107,14 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.1 +led-ble==1.1.6 # homeassistant.components.lektrico lektricowifi==0.0.43 +# homeassistant.components.letpot +letpot==0.4.0 + # homeassistant.components.foscam libpyfoscam==1.2.2 @@ -1139,6 +1154,7 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp # homeassistant.components.mcp_server mcp==1.1.2 @@ -1170,7 +1186,7 @@ microBeesPy==0.3.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.12.2 +millheater==0.12.3 # homeassistant.components.minio minio==7.1.12 @@ -1188,7 +1204,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1203,7 +1219,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 @@ -1215,7 +1231,7 @@ mutesync==0.0.1 mypermobil==0.1.8 # homeassistant.components.myuplink -myuplink==0.6.0 +myuplink==0.7.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 @@ -1230,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.0.8 +nexia==2.0.9 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1242,13 +1258,13 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.3.2 +nhc==0.4.10 # homeassistant.components.nibe_heatpump nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -1270,7 +1286,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.2.1 +numpy==2.2.2 # homeassistant.components.nyt_games nyt_games==0.4.4 @@ -1285,10 +1301,10 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.3 +ohme==1.3.2 # homeassistant.components.ollama -ollama==0.4.5 +ollama==0.4.7 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1296,8 +1312,11 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.12 + # homeassistant.components.onvif -onvif-zeep-async==3.1.13 +onvif-zeep-async==3.2.5 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1306,7 +1325,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.35.7 +openai==1.61.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1318,7 +1337,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.7 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1333,7 +1352,7 @@ ovoenergy==2.0.0 p1monitor==3.1.0 # homeassistant.components.mqtt -paho-mqtt==1.6.1 +paho-mqtt==2.1.0 # homeassistant.components.panasonic_viera panasonic-viera==0.4.2 @@ -1342,10 +1361,10 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.escea pescea==1.0.12 @@ -1363,7 +1382,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.4 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1372,7 +1391,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 @@ -1434,7 +1453,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.0 +py-synologydsm-api==2.7.0 # homeassistant.components.hdmi_cec pyCEC==0.5.2 @@ -1449,7 +1468,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.0 +pyHomee==1.2.7 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1492,7 +1511,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.0.2 +pybalboa==1.1.3 # homeassistant.components.blackbird pyblackbird==0.6 @@ -1501,7 +1520,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 @@ -1537,7 +1556,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2024.12.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1546,7 +1565,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.23 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 @@ -1564,7 +1583,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.0 +pyenphase==1.25.1 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1576,13 +1595,13 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 @@ -1594,7 +1613,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.12 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 @@ -1606,10 +1625,10 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.8.0 +pyheos==1.0.2 # homeassistant.components.hive -pyhiveapi==0.5.16 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 @@ -1627,7 +1646,7 @@ pyicloud==1.0.0 pyinsteon==1.6.3 # homeassistant.components.ipma -pyipma==3.0.8 +pyipma==3.0.9 # homeassistant.components.ipp pyipp==0.17.0 @@ -1636,7 +1655,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 @@ -1672,7 +1691,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 @@ -1690,7 +1709,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2023.5.0 +pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.23.0 @@ -1759,7 +1778,7 @@ pyoctoprintapi==0.1.12 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -1776,16 +1795,19 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.15.5 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.15 +pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.1 +pypck==0.8.5 + +# homeassistant.components.pglab +pypglab==0.0.3 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1800,7 +1822,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.9 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 @@ -1811,11 +1833,14 @@ pyps4-2ndscreen==1.3.1 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.nmbs +pyrail==0.4.1 + # homeassistant.components.rainbird pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1824,7 +1849,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.8 +pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 @@ -1842,7 +1867,7 @@ pysensibo==1.1.0 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 @@ -1851,25 +1876,25 @@ pysiaalarm==3.1.1 pysignalclirestapi==0.3.24 # homeassistant.components.sma -pysma==0.7.3 +pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==2.4.1 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 + +# homeassistant.components.smhi +pysmhi==1.0.0 # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.4 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1887,10 +1912,10 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.suez_water -pysuezV2==2.0.1 +pysuezV2==2.0.3 # homeassistant.components.switchbee pyswitchbee==1.8.3 @@ -1899,7 +1924,7 @@ pyswitchbee==1.8.3 pytautulli==23.1.1 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 @@ -1916,11 +1941,14 @@ python-fullykiosk==0.0.14 # homeassistant.components.sms # python-gammu==3.2.4 +# homeassistant.components.google_drive +python-google-drive-api==0.1.0 + # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.1 +python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==v7.0.1 +python-homewizard-energy==v8.3.2 # homeassistant.components.izone python-izone==1.2.9 @@ -1929,10 +1957,10 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.1 +python-linkplay==0.1.3 # homeassistant.components.matter python-matter-server==7.0.0 @@ -1954,31 +1982,34 @@ python-opensky==1.0.1 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.6.0 +python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.5.0 +python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 + +# homeassistant.components.snoo +python-snoo==0.6.0 # homeassistant.components.songpal python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.5 +python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 @@ -2008,9 +2039,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2018,7 +2046,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.15 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 @@ -2062,6 +2090,9 @@ pyzerproc==0.4.8 # homeassistant.components.qbittorrent qbittorrent-api==2024.2.59 +# homeassistant.components.qbus +qbusmqttapi==1.3.0 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2090,7 +2121,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.6 +reolink-aio==0.12.1 # homeassistant.components.rflink rflink==0.0.66 @@ -2105,7 +2136,7 @@ rokuecp==0.19.3 romy==0.0.10 # homeassistant.components.roomba -roombapy==1.8.1 +roombapy==1.9.0 # homeassistant.components.roon roonapi==0.1.6 @@ -2138,11 +2169,11 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2024.11.0 +securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.4 +sense-energy==0.13.5 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -2150,14 +2181,20 @@ sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 +# homeassistant.components.sensorpush_cloud +sensorpush-api==2.1.1 + # homeassistant.components.sensorpush sensorpush-ble==1.7.1 +# homeassistant.components.sensorpush_cloud +sensorpush-ha==1.3.2 + # homeassistant.components.sensoterra sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 @@ -2181,19 +2218,16 @@ simplisafe-python==2024.01.0 skyboxremote==0.0.6 # homeassistant.components.slack -slackclient==2.5.0 +slack_sdk==3.33.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 -# homeassistant.components.smhi -smhi-pkg==1.0.18 - # homeassistant.components.snapcast snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.6 +soco==0.30.9 # homeassistant.components.solarlog solarlog_cli==0.4.0 @@ -2235,7 +2269,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.1 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 @@ -2255,7 +2289,7 @@ sunweg==3.0.2 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.2.1 +switchbot-api==2.3.1 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 @@ -2278,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.2 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2287,34 +2321,34 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.4.2 +teslemetry-stream==0.6.10 # homeassistant.components.tessie tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro -thermopro-ble==0.10.0 +thermopro-ble==0.11.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 # homeassistant.components.todoist -todoist-api-python==2.1.2 +todoist-api-python==2.1.7 # homeassistant.components.tolo -tololib==1.1.0 +tololib==1.2.2 # homeassistant.components.toon toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 @@ -2344,13 +2378,13 @@ twilio==6.32.0 twitchAPI==4.2.1 # homeassistant.components.monarch_money -typedmonarchmoney==0.3.1 +typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.4.1 +uiprotect==7.5.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2359,7 +2393,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.upb -upb-lib==0.5.9 +upb-lib==0.6.0 # homeassistant.components.upcloud upcloud-api==2.6.0 @@ -2382,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2024.12.3 +velbus-aio==2025.1.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2391,7 +2425,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.2 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2410,7 +2444,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 @@ -2419,7 +2453,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.nasweb webio-api==0.1.11 @@ -2428,10 +2462,10 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.12.22 +weheat==2025.2.22 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 @@ -2443,7 +2477,7 @@ wiffi==1.1.2 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.15 +wolf-comm==0.0.19 # homeassistant.components.wyoming wyoming==1.5.4 @@ -2455,7 +2489,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.6.0 # homeassistant.components.knx xknxproject==3.8.1 @@ -2473,41 +2507,41 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.5.6 +yalexs-ble==2.5.7 # homeassistant.components.august # homeassistant.components.yale yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.7 +yolink-api==0.4.8 # homeassistant.components.youless -youless-api==2.1.2 +youless-api==2.2.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2024.12.23 +yt-dlp[default]==2025.02.19 # homeassistant.components.zamg zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.137.2 +zeroconf==0.145.1 # homeassistant.components.zeversolar zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.51 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 7760beef113..c133c4b544a 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -codespell==2.3.0 -ruff==0.8.6 +codespell==2.4.1 +ruff==0.9.8 yamllint==1.35.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 59ecec939f3..fa823fa4834 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -139,16 +139,16 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.7.0 +anyio==4.8.0 h11==0.14.0 -httpcore==1.0.5 +httpcore==1.0.7 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.2.1 +numpy==2.2.2 pandas~=2.2.3 # Constrain multidict to avoid typing issues @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.4 +pydantic==2.10.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -199,6 +199,10 @@ pysnmplib==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 +# Poetry is a build dependency. Installing it as a runtime dependency almost +# always indicates an issue with library requirements. +poetry==1000000000.0.0 + # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index c93d8fd4499..277696c669b 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -107,7 +107,13 @@ def get_config() -> Config: "--plugins", type=validate_plugins, default=ALL_PLUGIN_NAMES, - help="Comma-separate list of plugins to run. Valid plugin names: %(default)s", + help="Comma-separated list of plugins to run. Valid plugin names: %(default)s", + ) + parser.add_argument( + "--skip-plugins", + type=validate_plugins, + default=[], + help=f"Comma-separated list of plugins to skip. Valid plugin names: {ALL_PLUGIN_NAMES}", ) parser.add_argument( "--core-path", @@ -131,6 +137,9 @@ def get_config() -> Config: ): raise RuntimeError("Run from Home Assistant root") + if parsed.skip_plugins: + parsed.plugins = set(parsed.plugins) - set(parsed.skip_plugins) + return Config( root=parsed.core_path.absolute(), specific_integrations=parsed.integration_path, diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 83d406a0036..f842ec61b97 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -231,8 +231,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if integrations_path.read_text() != content + "\n": config.add_error( "config_flow", - "File integrations.json is not up to date. " - "Run python3 -m script.hassfest", + "File integrations.json is not up to date. Run python3 -m script.hassfest", fixable=True, ) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index d29571eaa83..b22027500dd 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -153,8 +153,6 @@ ALLOWED_USED_COMPONENTS = { } IGNORE_VIOLATIONS = { - # Has same requirement, gets defaults. - ("sql", "recorder"), # Sharing a base class ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), @@ -175,6 +173,10 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), + # The onboarding integration provides a limited backup API used during + # onboarding. The onboarding integration waits for the backup manager + # to be ready before calling any backup functionality. + ("onboarding", "backup"), } diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 022caee30cd..4bf6c3bb0a6 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -26,6 +26,24 @@ ENV \ ARG QEMU_CPU +# Home Assistant S6-Overlay +COPY rootfs / + +# Needs to be redefined inside the FROM statement to be set for RUN commands +ARG BUILD_ARCH +# Get go2rtc binary +RUN \ + case "${{BUILD_ARCH}}" in \ + "aarch64") go2rtc_suffix='arm64' ;; \ + "armhf") go2rtc_suffix='armv6' ;; \ + "armv7") go2rtc_suffix='arm' ;; \ + *) go2rtc_suffix=${{BUILD_ARCH}} ;; \ + esac \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \ + && chmod +x /bin/go2rtc \ + # Verify go2rtc can be executed + && go2rtc --version + # Install uv RUN pip3 install uv=={uv} @@ -56,24 +74,6 @@ RUN \ && python3 -m compileall \ homeassistant/homeassistant -# Home Assistant S6-Overlay -COPY rootfs / - -# Needs to be redefined inside the FROM statement to be set for RUN commands -ARG BUILD_ARCH -# Get go2rtc binary -RUN \ - case "${{BUILD_ARCH}}" in \ - "aarch64") go2rtc_suffix='arm64' ;; \ - "armhf") go2rtc_suffix='armv6' ;; \ - "armv7") go2rtc_suffix='arm' ;; \ - *) go2rtc_suffix=${{BUILD_ARCH}} ;; \ - esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \ - && chmod +x /bin/go2rtc \ - # Verify go2rtc can be executed - && go2rtc --version - WORKDIR /config """ @@ -94,6 +94,8 @@ COPY . /usr/src/homeassistant # Uv is only needed during build RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 3da4eb386de..c09d547ba79 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,9 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ + # Uv creates a lock file in /tmp + --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ @@ -22,8 +24,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.8,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.6 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.8 \ + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index fdbcf5bcb78..02c96930bf5 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -19,6 +19,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv +from script.util import sort_manifest as util_sort_manifest from .model import Config, Integration, ScaledQualityScaleTiers @@ -27,6 +28,8 @@ DOCUMENTATION_URL_HOST = "www.home-assistant.io" DOCUMENTATION_URL_PATH_PREFIX = "/integrations/" DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"} +_CORE_DOCUMENTATION_BASE = "https://www.home-assistant.io/integrations" + class NonScaledQualityScaleTiers(StrEnum): """Supported manifest quality scales.""" @@ -117,19 +120,26 @@ NO_IOT_CLASS = [ ] -def documentation_url(value: str) -> str: +def core_documentation_url(value: str) -> str: """Validate that a documentation url has the correct path and domain.""" if value in DOCUMENTATION_URL_EXCEPTIONS: return value + if not value.startswith(_CORE_DOCUMENTATION_BASE): + raise vol.Invalid( + f"Documentation URL does not begin with {_CORE_DOCUMENTATION_BASE}" + ) + return value + + +def custom_documentation_url(value: str) -> str: + """Validate that a custom integration documentation url is correct.""" parsed_url = urlparse(value) if parsed_url.scheme != DOCUMENTATION_URL_SCHEMA: raise vol.Invalid("Documentation url is not prefixed with https") - if parsed_url.netloc == DOCUMENTATION_URL_HOST and not parsed_url.path.startswith( - DOCUMENTATION_URL_PATH_PREFIX - ): + if value.startswith(_CORE_DOCUMENTATION_BASE): raise vol.Invalid( - "Documentation url does not begin with www.home-assistant.io/integrations" + "Documentation URL should point to the custom integration documentation" ) return value @@ -258,7 +268,7 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( } ) ], - vol.Required("documentation"): vol.All(vol.Url(), documentation_url), + vol.Required("documentation"): vol.All(vol.Url(), core_documentation_url), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -293,6 +303,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema: CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { + vol.Required("documentation"): vol.All(vol.Url(), custom_documentation_url), vol.Optional("version"): vol.All(str, verify_version), vol.Optional("issue_tracker"): vol.Url(), vol.Optional("import_executor"): bool, @@ -366,20 +377,20 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No validate_version(integration) -_SORT_KEYS = {"domain": ".domain", "name": ".name"} - - -def _sort_manifest_keys(key: str) -> str: - return _SORT_KEYS.get(key, key) - - def sort_manifest(integration: Integration, config: Config) -> bool: """Sort manifest.""" - keys = list(integration.manifest.keys()) - if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: - manifest = {key: integration.manifest[key] for key in keys_sorted} + if integration.manifest_path is None: + integration.add_error( + "manifest", + "Manifest path not set, unable to sort manifest keys", + ) + return False + + if util_sort_manifest(integration.manifest): if config.action == "generate": - integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + integration.manifest_path.write_text( + json.dumps(integration.manifest, indent=2) + "\n" + ) text = "have been sorted" else: text = "are not sorted correctly" diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 08ded687096..1ca4178d9c2 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -157,8 +157,10 @@ class Integration: @property def core(self) -> bool: """Core integration.""" - return self.path.as_posix().startswith( - self._config.core_integrations_path.as_posix() + return ( + self.path.absolute() + .as_posix() + .startswith(self._config.core_integrations_path.as_posix()) ) @property diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1d7f2b5ed88..ac27df85ccc 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -41,7 +41,7 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { ), "show_error_codes": "true", "follow_imports": "normal", - # "enable_incomplete_feature": ", ".join( # noqa: FLY002 + # "enable_incomplete_feature": ", ".join( # [] # ), # Enable some checks globally. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index c24f1d9af26..9ddce29a4f3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -218,7 +218,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "bluetooth_adapters", "bluetooth_le_tracker", "bluetooth_tracker", - "bmw_connected_drive", "bond", "bosch_shc", "braviatv", @@ -336,7 +335,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "egardia", "eight_sleep", "electrasmart", - "electric_kiwi", "eliqonline", "elkm1", "elmax", @@ -392,7 +390,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "fjaraskupan", "fleetgo", "flexit", - "flexit_bacnet", "flic", "flick_electric", "flipr", @@ -596,7 +593,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "linux_battery", "lirc", "litejet", - "litterrobot", "livisi", "llamalab_automate", "local_calendar", @@ -653,7 +649,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", @@ -672,7 +667,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "motion_blinds", "motionblinds_ble", "motioneye", - "motionmount", "mpd", "mqtt_eventstream", "mqtt_json", @@ -742,7 +736,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "omnilogic", "oncue", "ondilo_ico", - "onewire", "onvif", "open_meteo", "openai_conversation", @@ -819,7 +812,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -877,7 +869,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rtorrent", "rtsp_to_webrtc", "ruckus_unleashed", - "russound_rnet", "ruuvi_gateway", "ruuvitag_ble", "rympro", @@ -983,7 +974,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "swisscom", "switch_as_x", "switchbee", - "switchbot", "switchbot_cloud", "switcher_kis", "switchmate", @@ -1043,7 +1033,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", @@ -1121,7 +1110,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "weatherflow_cloud", "weatherkit", "webmin", - "weheat", "wemo", "whirlpool", "whois", @@ -1295,7 +1283,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "brottsplatskartan", "browser", "brunt", - "bring", "bryant_evolution", "bsblan", "bt_home_hub_5", @@ -1364,7 +1351,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "directv", "discogs", "discord", - "discovergy", "dlib_face_detect", "dlib_face_identify", "dlink", @@ -1406,7 +1392,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "egardia", "eight_sleep", "electrasmart", - "electric_kiwi", "elevenlabs", "eliqonline", "elkm1", @@ -1466,7 +1451,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "fjaraskupan", "fleetgo", "flexit", - "flexit_bacnet", "flic", "flick_electric", "flipr", @@ -1546,14 +1530,12 @@ INTEGRATIONS_WITHOUT_SCALE = [ "gstreamer", "gtfs", "guardian", - "habitica", "harman_kardon_avr", "harmony", "hassio", "haveibeenpwned", "hddtemp", "hdmi_cec", - "heos", "heatmiser", "here_travel_time", "hikvision", @@ -1606,7 +1588,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "intellifire", "intesishome", "ios", - "iron_os", "iotawatt", "iotty", "iperf3", @@ -1681,7 +1662,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "linux_battery", "lirc", "litejet", - "litterrobot", "livisi", "llamalab_automate", "local_calendar", @@ -1740,7 +1720,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", @@ -1759,9 +1738,7 @@ INTEGRATIONS_WITHOUT_SCALE = [ "motion_blinds", "motionblinds_ble", "motioneye", - "motionmount", "mpd", - "mqtt", "mqtt_eventstream", "mqtt_json", "mqtt_room", @@ -1884,7 +1861,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pioneer", "pjlink", "plaato", - "plugwise", "plant", "plex", "plum_lightpad", @@ -1912,7 +1888,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -2142,7 +2117,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", @@ -2189,7 +2163,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "velux", "venstar", "vera", - "velbus", "verisure", "versasense", "version", diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py index d11bcaf2cec..45eaafde0b5 100644 --- a/script/hassfest/quality_scale_validation/discovery.py +++ b/script/hassfest/quality_scale_validation/discovery.py @@ -55,8 +55,7 @@ def validate( config_flow = ast_parse_module(config_flow_file) if not (_has_discovery_function(config_flow)): return [ - f"Integration is missing one of {CONFIG_FLOW_STEPS} " - f"in {config_flow_file}" + f"Integration is missing one of {CONFIG_FLOW_STEPS} in {config_flow_file}" ] return None diff --git a/script/hassfest/quality_scale_validation/runtime_data.py b/script/hassfest/quality_scale_validation/runtime_data.py index cfc4c5224de..3562d897967 100644 --- a/script/hassfest/quality_scale_validation/runtime_data.py +++ b/script/hassfest/quality_scale_validation/runtime_data.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform from script.hassfest import ast_parse_module from script.hassfest.model import Config, Integration -_ANNOTATION_MATCH = re.compile(r"^[A-Za-z]+ConfigEntry$") +_ANNOTATION_MATCH = re.compile(r"^[A-Za-z][A-Za-z0-9]+ConfigEntry$") _FUNCTIONS: dict[str, dict[str, int]] = { "__init__": { # based on ComponentProtocol "async_migrate_entry": 2, diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py index c1373032ff8..1f5a5665835 100644 --- a/script/hassfest/quality_scale_validation/strict_typing.py +++ b/script/hassfest/quality_scale_validation/strict_typing.py @@ -43,7 +43,11 @@ def _check_requirements_are_typed(integration: Integration) -> list[str]: if not any(file for file in distribution.files if file.name == "py.typed"): # no py.typed file - invalid_requirements.append(requirement) + try: + metadata.distribution(f"types-{requirement_name}") + except metadata.PackageNotFoundError: + # also no stubs-only package + invalid_requirements.append(requirement) return invalid_requirements diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2fb70b6e0be..c257f185f51 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -185,6 +185,8 @@ def gen_data_entry_schema( vol.Optional("abort"): {str: translation_value_validator}, vol.Optional("progress"): {str: translation_value_validator}, vol.Optional("create_entry"): {str: translation_value_validator}, + vol.Optional("initiate_flow"): {str: translation_value_validator}, + vol.Optional("entry_type"): translation_value_validator, } if flow_title == REQUIRED: schema[vol.Required("title")] = translation_value_validator @@ -285,6 +287,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: "user" if integration.integration_type == "helper" else None ), ), + vol.Optional("config_subentries"): cv.schema_with_slug_keys( + gen_data_entry_schema( + config=config, + integration=integration, + flow_title=REMOVED, + require_step_title=False, + ), + slug_validator=vol.Any("_", cv.slug), + ), vol.Optional("options"): gen_data_entry_schema( config=config, integration=integration, @@ -454,7 +465,7 @@ ONBOARDING_SCHEMA = vol.Schema( ) -def validate_translation_file( # noqa: C901 +def validate_translation_file( config: Config, integration: Integration, all_strings: dict[str, Any] | None, @@ -510,8 +521,8 @@ def validate_translation_file( # noqa: C901 ): integration.add_error( "translations", - "Don't specify title in translation strings if it's a brand " - "name or add exception to ALLOW_NAME_TRANSLATION", + "Don't specify title in translation strings if it's " + "a brand name or add exception to ALLOW_NAME_TRANSLATION", ) if config.specific_integrations: @@ -532,12 +543,15 @@ def validate_translation_file( # noqa: C901 if parts or key not in search: integration.add_error( "translations", - f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}", + f"{reference['source']} contains invalid reference" + f"{reference['ref']}: Could not find {key}", ) elif match := re.match(RE_REFERENCE, search[key]): integration.add_error( "translations", - f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"", + "Lokalise supports only one level of references: " + f'"{reference["source"]}" should point to directly ' + f'to "{match.groups()[0]}"', ) diff --git a/script/licenses.py b/script/licenses.py index 464a2fc456b..aa15a58f3bd 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -199,7 +199,6 @@ EXCEPTIONS = { "pigpio", # https://github.com/joan2937/pigpio/pull/608 "pymitv", # MIT "pybbox", # https://github.com/HydrelioxGitHub/pybbox/pull/5 - "pyeconet", # https://github.com/w1ll1am23/pyeconet/pull/41 "pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6 "pyvera", # https://github.com/maximvelichko/pyvera/pull/164 "repoze.lru", diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 93c787df50f..243ea9507f7 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -8,6 +8,7 @@ import sys from script.util import valid_integration from . import docs, error, gather_info, generate +from .model import Info TEMPLATES = [ p.name for p in (Path(__file__).parent / "templates").glob("*") if p.is_dir() @@ -28,6 +29,40 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() +def run_process(name: str, cmd: list[str], info: Info) -> None: + """Run a sub process and handle the result. + + :param name: The name of the sub process used in reporting. + :param cmd: The sub process arguments. + :param info: The Info object. + :raises subprocess.CalledProcessError: If the subprocess failed. + + If the sub process was successful print a success message, otherwise + print an error message and raise a subprocess.CalledProcessError. + """ + print(f"Command: {' '.join(cmd)}") + print() + result: subprocess.CompletedProcess = subprocess.run(cmd, check=False) + if result.returncode == 0: + print() + print(f"Completed {name} successfully.") + print() + return + + print() + print(f"Fatal Error: {name} failed with exit code {result.returncode}") + print() + if info.is_new: + print("This is a bug, please report an issue!") + else: + print( + "This may be an existing issue with your integration,", + "if so fix and run `script.scaffold` again,", + "otherwise please report an issue.", + ) + result.check_returncode() + + def main() -> int: """Scaffold an integration.""" if not Path("requirements_all.txt").is_file(): @@ -64,20 +99,32 @@ def main() -> int: if args.template != "integration": generate.generate(args.template, info) - pipe_null = {} if args.develop else {"stdout": subprocess.DEVNULL} - + # Always output sub commands as the output will contain useful information if a command fails. print("Running hassfest to pick up new information.") - subprocess.run(["python", "-m", "script.hassfest"], **pipe_null, check=True) - print() + run_process( + "hassfest", + [ + "python", + "-m", + "script.hassfest", + "--integration-path", + str(info.integration_dir), + "--skip-plugins", + "quality_scale", # Skip quality scale as it will fail for newly generated integrations. + ], + info, + ) print("Running gen_requirements_all to pick up new information.") - subprocess.run( - ["python", "-m", "script.gen_requirements_all"], **pipe_null, check=True + run_process( + "gen_requirements_all", + ["python", "-m", "script.gen_requirements_all"], + info, ) - print() - print("Running script/translations_develop to pick up new translation strings.") - subprocess.run( + print("Running translations to pick up new translation strings.") + run_process( + "translations", [ "python", "-m", @@ -86,15 +133,13 @@ def main() -> int: "--integration", info.domain, ], - **pipe_null, - check=True, + info, ) - print() if args.develop: print("Running tests") - print(f"$ python3 -b -m pytest -vvv tests/components/{info.domain}") - subprocess.run( + run_process( + "pytest", [ "python3", "-b", @@ -103,9 +148,8 @@ def main() -> int: "-vvv", f"tests/components/{info.domain}", ], - check=True, + info, ) - print() docs.print_relevant_docs(args.template, info) @@ -115,6 +159,8 @@ def main() -> int: if __name__ == "__main__": try: sys.exit(main()) + except subprocess.CalledProcessError as err: + sys.exit(err.returncode) except error.ExitApp as err: print() print(f"Fatal Error: {err.reason}") diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index cfa2669ebfe..d90e01c3ebd 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -93,7 +93,7 @@ def gather_new_integration(determine_auth: bool) -> Info: "prompt": ( f"""How will your integration gather data? -Valid values are {', '.join(SUPPORTED_IOT_CLASSES)} +Valid values are {", ".join(SUPPORTED_IOT_CLASSES)} More info @ https://developers.home-assistant.io/docs/creating_integration_manifest#iot-class """ diff --git a/script/scaffold/model.py b/script/scaffold/model.py index 3b5a5e50fe4..e3a7be210ab 100644 --- a/script/scaffold/model.py +++ b/script/scaffold/model.py @@ -4,9 +4,12 @@ from __future__ import annotations import json from pathlib import Path +from typing import Any import attr +from script.util import sort_manifest + from .const import COMPONENT_DIR, TESTS_DIR @@ -44,16 +47,19 @@ class Info: """Path to the manifest.""" return COMPONENT_DIR / self.domain / "manifest.json" - def manifest(self) -> dict: + def manifest(self) -> dict[str, Any]: """Return integration manifest.""" return json.loads(self.manifest_path.read_text()) def update_manifest(self, **kwargs) -> None: """Update the integration manifest.""" print(f"Updating {self.domain} manifest: {kwargs}") - self.manifest_path.write_text( - json.dumps({**self.manifest(), **kwargs}, indent=2) + "\n" - ) + + # Sort keys in manifest so we don't trigger hassfest errors. + manifest: dict[str, Any] = {**self.manifest(), **kwargs} + sort_manifest(manifest) + + self.manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") @property def strings_path(self) -> Path: diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 0b752e71013..11759c48cf3 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with API object # TODO Rename type alias and update all entry annotations @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 9a712834bae..66209f77e6a 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -15,7 +15,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -32,7 +32,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -63,7 +63,7 @@ async def test_form_invalid_auth( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} # Make sure the config flow tests finish with either an @@ -83,7 +83,7 @@ async def test_form_invalid_auth( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", @@ -114,7 +114,7 @@ async def test_form_cannot_connect( }, ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} # Make sure the config flow tests finish with either an @@ -135,7 +135,7 @@ async def test_form_cannot_connect( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" assert result["data"] == { CONF_HOST: "1.1.1.1", diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py index 06b91f51949..ba56b958273 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] # TODO Create ConfigEntry type alias with API object # Alias name should be prefixed by integration name @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO 3. Store an API object for your platforms to access # entry.runtime_data = MyAPI(...) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -32,4 +32,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/config_flow_helper/integration/sensor.py b/script/scaffold/templates/config_flow_helper/integration/sensor.py index 741b2e85eb2..9c00dd568eb 100644 --- a/script/scaffold/templates/config_flow_helper/integration/sensor.py +++ b/script/scaffold/templates/config_flow_helper/integration/sensor.py @@ -7,13 +7,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize NEW_NAME config entry.""" registry = er.async_get(hass) diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py index 8e7854835d8..fbf705cfb26 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -24,7 +24,7 @@ async def test_config_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None result = await hass.config_entries.flow.async_configure( @@ -33,7 +33,7 @@ async def test_config_flow( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "My NEW_DOMAIN" assert result["data"] == {} assert result["options"] == { @@ -83,7 +83,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "entity_id") == input_sensor_1_entity_id @@ -94,7 +94,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "entity_id": input_sensor_2_entity_id, }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { "entity_id": input_sensor_2_entity_id, "name": "My NEW_DOMAIN", diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index b8403392471..8eaf8b0e25a 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -11,7 +11,7 @@ from . import api # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT] # TODO Create ConfigEntry type alias with ConfigEntryAuth or AsyncConfigEntryAuth object # TODO Rename type alias and update all entry annotations @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> aiohttp_client.async_get_clientsession(hass), session ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -45,4 +45,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> # TODO Update entry annotation async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/script/scaffold/templates/integration/integration/manifest.json b/script/scaffold/templates/integration/integration/manifest.json index 7235500391d..15bc84a9b5e 100644 --- a/script/scaffold/templates/integration/integration/manifest.json +++ b/script/scaffold/templates/integration/integration/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/NEW_DOMAIN", "homekit": {}, "iot_class": "IOT_CLASS", + "quality_scale": "bronze", "requirements": [], "ssdp": [], "zeroconf": [] diff --git a/script/split_tests.py b/script/split_tests.py index c64de46a068..0018472e54e 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -79,7 +79,7 @@ class BucketHolder: """Create output file.""" with Path("pytest_buckets.txt").open("w") as file: for idx, bucket in enumerate(self._buckets): - print(f"Bucket {idx+1} has {bucket.total_tests} tests") + print(f"Bucket {idx + 1} has {bucket.total_tests} tests") file.write(bucket.get_paths_line()) diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index f92f90115ce..ac608a1aa0e 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -70,8 +70,10 @@ def run(): # If we want to only add references to own integrations # but not include entity integrations if ( - args.limit_reference - and (key_integration != key_to_reference_integration and not is_common) + ( + args.limit_reference + and (key_integration != key_to_reference_integration and not is_common) + ) # Do not create self-references in entity integrations or key_integration in Platform.__members__.values() ): diff --git a/script/translations/develop.py b/script/translations/develop.py index 9e3a2ded046..00ac7bf98ac 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -4,7 +4,6 @@ import argparse import json from pathlib import Path import re -from shutil import rmtree import sys from . import download, upload @@ -83,9 +82,10 @@ def run_single(translations, flattened_translations, integration): ) if download.DOWNLOAD_DIR.is_dir(): - rmtree(str(download.DOWNLOAD_DIR)) - - download.DOWNLOAD_DIR.mkdir(parents=True) + for lang_file in download.DOWNLOAD_DIR.glob("*.json"): + lang_file.unlink() + else: + download.DOWNLOAD_DIR.mkdir(parents=True) (download.DOWNLOAD_DIR / "en.json").write_text( json.dumps({"component": {integration: translations["component"][integration]}}) diff --git a/script/util.py b/script/util.py index b7c37c72102..c9fada38c80 100644 --- a/script/util.py +++ b/script/util.py @@ -1,6 +1,7 @@ """Utility functions for the scaffold script.""" import argparse +from typing import Any from .const import COMPONENT_DIR @@ -13,3 +14,23 @@ def valid_integration(integration): ) return integration + + +_MANIFEST_SORT_KEYS = {"domain": ".domain", "name": ".name"} + + +def _sort_manifest_keys(key: str) -> str: + """Sort manifest keys.""" + return _MANIFEST_SORT_KEYS.get(key, key) + + +def sort_manifest(manifest: dict[str, Any]) -> bool: + """Sort manifest.""" + keys = list(manifest) + if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: + sorted_manifest = {key: manifest[key] for key in keys_sorted} + manifest.clear() + manifest.update(sorted_manifest) + return True + + return False diff --git a/tests/common.py b/tests/common.py index ac6f10b8c44..df674d1824c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,7 +15,7 @@ from collections.abc import ( ) from contextlib import asynccontextmanager, contextmanager, suppress from datetime import UTC, datetime, timedelta -from enum import Enum +from enum import Enum, StrEnum import functools as ft from functools import lru_cache from io import StringIO @@ -31,7 +31,6 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import pytest from syrupy import SnapshotAssertion -from typing_extensions import TypeVar import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -87,15 +86,18 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util, ulid as ulid_util from homeassistant.util.async_ import ( _SHUTDOWN_RUN_CALLBACK_THREADSAFE, get_scheduled_timer_handles, run_callback_threadsafe, ) -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.json import ( JsonArrayType, @@ -106,22 +108,27 @@ from homeassistant.util.json import ( json_loads_object, ) from homeassistant.util.signal_type import SignalType -import homeassistant.util.ulid as ulid_util from homeassistant.util.unit_system import METRIC_SYSTEM -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, ) -_DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=dict[str, Any]) - _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" CLIENT_REDIRECT_URI = "https://example.com/app/callback" +class QualityScaleStatus(StrEnum): + """Source of core configuration.""" + + DONE = "done" + EXEMPT = "exempt" + TODO = "todo" + + async def async_get_device_automations( hass: HomeAssistant, automation_type: device_automation.DeviceAutomationType, @@ -403,6 +410,25 @@ def async_mock_intent(hass: HomeAssistant, intent_typ: str) -> list[intent.Inten return intents +class MockMqttReasonCode: + """Class to fake a MQTT ReasonCode.""" + + value: int + is_failure: bool + + def __init__( + self, value: int = 0, is_failure: bool = False, name: str = "Success" + ) -> None: + """Initialize the mock reason code.""" + self.value = value + self.is_failure = is_failure + self._name = name + + def getName(self) -> str: + """Return the name of the reason code.""" + return self._name + + @callback def async_fire_mqtt_message( hass: HomeAssistant, @@ -1000,6 +1026,7 @@ class MockConfigEntry(config_entries.ConfigEntry): reason=None, source=config_entries.SOURCE_USER, state=None, + subentries_data=None, title="Mock Title", unique_id=None, version=1, @@ -1016,6 +1043,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, + "subentries_data": subentries_data or (), "title": title, "unique_id": unique_id, "version": version, @@ -1088,6 +1116,28 @@ class MockConfigEntry(config_entries.ConfigEntry): }, ) + async def start_subentry_reconfigure_flow( + self, + hass: HomeAssistant, + subentry_flow_type: str, + subentry_id: str, + *, + show_advanced_options: bool = False, + ) -> ConfigFlowResult: + """Start a subentry reconfiguration flow.""" + if self.entry_id not in hass.config_entries._entries: + raise ValueError( + "Config entry must be added to hass to start reconfiguration flow" + ) + return await hass.config_entries.subentries.async_init( + (self.entry_id, subentry_flow_type), + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "subentry_id": subentry_id, + "show_advanced_options": show_advanced_options, + }, + ) + async def start_reauth_flow( hass: HomeAssistant, @@ -1189,16 +1239,16 @@ def assert_setup_component(count, domain=None): yield config if domain is None: - assert ( - len(config) == 1 - ), f"assert_setup_component requires DOMAIN: {list(config.keys())}" + assert len(config) == 1, ( + f"assert_setup_component requires DOMAIN: {list(config.keys())}" + ) domain = list(config.keys())[0] res = config.get(domain) res_len = 0 if res is None else len(res) - assert ( - res_len == count - ), f"setup_component failed, expected {count} got {res_len}: {res}" + assert res_len == count, ( + f"setup_component failed, expected {count} got {res_len}: {res}" + ) def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: @@ -1537,7 +1587,7 @@ def mock_platform( module_cache[platform_path] = module or Mock() -def async_capture_events( +def async_capture_events[_DataT: Mapping[str, Any] = dict[str, Any]]( hass: HomeAssistant, event_name: EventType[_DataT] | str ) -> list[Event[_DataT]]: """Create a helper that captures events.""" @@ -1785,7 +1835,7 @@ def setup_test_component_platform( async def _async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a test component platform.""" async_add_entities(entities) @@ -1806,9 +1856,9 @@ async def snapshot_platform( """Snapshot a platform.""" entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) for entity_entry in entity_entries: assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") assert entity_entry.disabled_by is None, "Please enable all entities." @@ -1817,18 +1867,20 @@ async def snapshot_platform( assert state == snapshot(name=f"{entity_entry.entity_id}-state") -def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: - """Reset translation cache for specified components. - - Use this if you are mocking a core component (for example via - mock_integration), to ensure that the mocked translations are not - persisted in the shared session cache. - """ - translations_cache = translation._async_get_translations_cache(hass) - for loaded_components in translations_cache.cache_data.loaded.values(): - for component_to_unload in components: - loaded_components.discard(component_to_unload) - for loaded_categories in translations_cache.cache_data.cache.values(): - for loaded_components in loaded_categories.values(): - for component_to_unload in components: - loaded_components.pop(component_to_unload, None) +@lru_cache +def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: + """Load quality scale for integration.""" + quality_scale_file = pathlib.Path( + f"homeassistant/components/{integration}/quality_scale.yaml" + ) + if not quality_scale_file.exists(): + return {} + raw = load_yaml_dict(quality_scale_file) + return { + rule: ( + QualityScaleStatus(details) + if isinstance(details, str) + else QualityScaleStatus(details["status"]) + ) + for rule, details in raw["rules"].items() + } diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr index 113b5f1501e..a9c52c052a3 100644 --- a/tests/components/acaia/snapshots/test_binary_sensor.ambr +++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr index cd91ca1a17a..11827c0997f 100644 --- a/tests/components/acaia/snapshots/test_button.ambr +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 7011b20f68c..c7a11cb58df 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr index c3c8ce966ee..9214db4f102 100644 --- a/tests/components/acaia/snapshots/test_sensor.ambr +++ b/tests/components/acaia/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 3468d638bc0..257d29ae844 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -80,6 +81,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +147,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +213,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +279,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +338,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -385,6 +391,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +447,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -489,6 +497,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -537,6 +546,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -585,6 +595,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -633,6 +644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -681,6 +693,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +742,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -777,6 +791,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -825,6 +840,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -873,6 +889,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -921,6 +938,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -969,6 +987,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1016,6 +1035,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1063,6 +1083,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +1131,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1157,6 +1179,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1204,6 +1227,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1251,6 +1275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1298,6 +1323,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1345,6 +1371,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1392,6 +1419,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1441,6 +1469,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1491,6 +1520,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1540,6 +1570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1589,6 +1620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1638,6 +1670,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1687,6 +1720,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1736,6 +1770,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1784,6 +1819,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1832,6 +1868,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1880,6 +1917,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1928,6 +1966,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1978,6 +2017,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2028,6 +2068,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2077,6 +2118,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2126,6 +2168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2175,6 +2218,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2224,6 +2268,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2275,6 +2320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2328,6 +2374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2387,6 +2434,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2440,6 +2488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2489,6 +2538,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2538,6 +2588,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2587,6 +2638,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2636,6 +2688,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2687,6 +2740,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2737,6 +2791,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2786,6 +2841,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2835,6 +2891,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2884,6 +2941,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2933,6 +2991,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2982,6 +3041,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3031,6 +3091,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3080,6 +3141,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3129,6 +3191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3178,6 +3241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3229,6 +3293,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3279,6 +3344,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3328,6 +3394,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3377,6 +3444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3426,6 +3494,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3475,6 +3544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3524,6 +3594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3573,6 +3644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3622,6 +3694,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3671,6 +3744,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3720,6 +3794,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3769,6 +3844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3818,6 +3894,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3867,6 +3944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3916,6 +3994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3965,6 +4044,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4014,6 +4094,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4063,6 +4144,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4112,6 +4194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4161,6 +4244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4210,6 +4294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4261,6 +4346,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4311,6 +4397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4359,6 +4446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4407,6 +4495,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4455,6 +4544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4503,6 +4593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4551,6 +4642,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4599,6 +4691,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4647,6 +4740,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4695,6 +4789,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4743,6 +4838,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4791,6 +4887,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4840,6 +4937,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4889,6 +4987,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4938,6 +5037,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4987,6 +5087,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5038,6 +5139,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5088,6 +5190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5137,6 +5240,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5186,6 +5290,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5235,6 +5340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5284,6 +5390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5335,6 +5442,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5387,6 +5495,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5439,6 +5548,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5489,6 +5599,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5539,6 +5650,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5589,6 +5701,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5639,6 +5752,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5689,6 +5803,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5739,6 +5854,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5789,6 +5905,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5839,6 +5956,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5889,6 +6007,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5939,6 +6058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5991,6 +6111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6041,6 +6162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6091,6 +6213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6141,6 +6264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6191,6 +6315,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6241,6 +6366,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6291,6 +6417,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6341,6 +6468,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6391,6 +6519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6441,6 +6570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6491,6 +6621,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index cbe1891d216..862d79c2fde 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -247,6 +247,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/acmeda/conftest.py b/tests/components/acmeda/conftest.py index 2c980351c09..4a803711959 100644 --- a/tests/components/acmeda/conftest.py +++ b/tests/components/acmeda/conftest.py @@ -1,5 +1,8 @@ """Define fixtures available for all Acmeda tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + import pytest from homeassistant.components.acmeda.const import DOMAIN @@ -18,3 +21,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) mock_config_entry.add_to_hass(hass) return mock_config_entry + + +@pytest.fixture +def mock_hub_run() -> Generator[AsyncMock]: + """Mock the hub run method.""" + with patch("homeassistant.components.acmeda.hub.aiopulse.Hub.run") as mock_run: + yield mock_run diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 5227d283f25..7b92c1aac3b 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -28,13 +28,6 @@ def mock_hub_discover(): yield mock_discover -@pytest.fixture -def mock_hub_run(): - """Mock the hub run method.""" - with patch("aiopulse.Hub.run") as mock_run: - yield mock_run - - async def async_generator(items): """Async yields items provided in a list.""" for item in items: @@ -56,9 +49,8 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 -async def test_show_form_one_hub( - hass: HomeAssistant, mock_hub_discover, mock_hub_run -) -> None: +@pytest.mark.usefixtures("mock_hub_run") +async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) @@ -102,9 +94,8 @@ async def test_show_form_two_hubs(hass: HomeAssistant, mock_hub_discover) -> Non assert len(mock_hub_discover.mock_calls) == 1 -async def test_create_second_entry( - hass: HomeAssistant, mock_hub_run, mock_hub_discover -) -> None: +@pytest.mark.usefixtures("mock_hub_run") +async def test_create_second_entry(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when a second hub is discovered.""" dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py index 0d908ecc915..d5b6997ee33 100644 --- a/tests/components/acmeda/test_cover.py +++ b/tests/components/acmeda/test_cover.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_hub_run") async def test_cover_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py index 3d7090ce7dd..12195d3aec4 100644 --- a/tests/components/acmeda/test_sensor.py +++ b/tests/components/acmeda/test_sensor.py @@ -1,5 +1,7 @@ """Define tests for the Acmeda config flow.""" +import pytest + from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_hub_run") async def test_sensor_id_migration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 0e40cce1b86..165e682de68 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -22,6 +22,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index d0f577c8068..d4fca62e98b 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.components.weather import ATTR_CONDITION_SNOWY from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .util import async_init_integration diff --git a/tests/components/airgradient/snapshots/test_button.ambr b/tests/components/airgradient/snapshots/test_button.ambr index fa3f8994c3c..85ad29f98f2 100644 --- a/tests/components/airgradient/snapshots/test_button.ambr +++ b/tests/components/airgradient/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr index a96dfb95382..624a6f76f8d 100644 --- a/tests/components/airgradient/snapshots/test_diagnostics.ambr +++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr @@ -25,13 +25,13 @@ 'nitrogen_index': 1, 'pm003_count': 270, 'pm01': 22, - 'pm02': 34, + 'pm02': 34.0, 'pm10': 41, 'raw_ambient_temperature': 27.96, - 'raw_nitrogen': 16931, + 'raw_nitrogen': 16931.0, 'raw_pm02': 34, 'raw_relative_humidity': 48.0, - 'raw_total_volatile_organic_component': 31792, + 'raw_total_volatile_organic_component': 31792.0, 'rco2': 778, 'relative_humidity': 47.0, 'serial_number': '84fce612f5b8', diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 72cb12535f1..4e0c8027b43 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/airgradient/snapshots/test_number.ambr b/tests/components/airgradient/snapshots/test_number.ambr index 87df8757eeb..f847a4a472d 100644 --- a/tests/components/airgradient/snapshots/test_number.ambr +++ b/tests/components/airgradient/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr index b8fca4a110b..cc080560ae5 100644 --- a/tests/components/airgradient/snapshots/test_select.ambr +++ b/tests/components/airgradient/snapshots/test_select.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +75,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -129,6 +131,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +244,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -299,6 +304,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -360,6 +366,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +548,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -600,6 +610,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 941369ff266..374d9a60e4e 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +217,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +271,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -315,6 +321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -368,6 +375,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +430,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +478,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -519,6 +529,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -710,7 +724,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '34', + 'state': '34.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_nox-entry] @@ -722,6 +736,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -760,7 +775,59 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16931', + 'state': '16931.0', + }) +# --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Raw PM2.5', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_pm02', + 'unique_id': '84fce612f5b8-pm02_raw', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Airgradient Raw PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_voc-entry] @@ -772,6 +839,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -810,7 +878,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '31792', + 'state': '31792.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_signal_strength-entry] @@ -822,6 +890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -873,6 +942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -924,6 +994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -971,6 +1042,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1019,6 +1091,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1069,6 +1142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1116,6 +1190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1166,6 +1241,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1204,7 +1280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16359', + 'state': '16359.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_raw_voc-entry] @@ -1216,6 +1292,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1254,7 +1331,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30802', + 'state': '30802.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_signal_strength-entry] @@ -1266,6 +1343,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1317,6 +1395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1364,6 +1443,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_switch.ambr b/tests/components/airgradient/snapshots/test_switch.ambr index 752355dbe97..ae2116d5b29 100644 --- a/tests/components/airgradient/snapshots/test_switch.ambr +++ b/tests/components/airgradient/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_update.ambr b/tests/components/airgradient/snapshots/test_update.ambr index 1f944bb528b..53c815629f2 100644 --- a/tests/components/airgradient/snapshots/test_update.ambr +++ b/tests/components/airgradient/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 83de2c2f048..2440669b6e8 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -3,14 +3,16 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -97,3 +99,37 @@ async def test_cloud_creates_no_button( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + mock_airgradient_client.request_co2_calibration.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.airgradient_calibrate_co2_sensor", + }, + blocking=True, + ) diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 8927947c40e..4c035b09aa7 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -10,11 +10,11 @@ from airgradient import ( ) from homeassistant.components.airgradient.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -296,3 +296,99 @@ async def test_user_flow_works_discovery( # Verify the discovery flow was aborted assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.131", + } + + +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + mock_new_airgradient_client.get_current_measures.side_effect = ( + AirGradientConnectionError() + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_new_airgradient_client.get_current_measures.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.132", + } + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts with unique id mismatch.""" + mock_config_entry.add_to_hass(hass) + + mock_new_airgradient_client.get_current_measures.return_value.serial_number = ( + "84fce612f5b9" + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.132"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data == { + CONF_HOST: "10.0.0.131", + } diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 7aabda8f81c..2cbd72d033a 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -15,6 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,3 +101,37 @@ async def test_cloud_creates_no_number( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.set_display_brightness.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50}, + target={ATTR_ENTITY_ID: "number.airgradient_display_brightness"}, + blocking=True, + ) diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index de4a7beaaa7..b8ae2cefa4e 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -15,6 +15,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -94,3 +95,39 @@ async def test_cloud_creates_no_number( await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.set_configuration_control.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.airgradient_configuration_source", + ATTR_OPTION: "local", + }, + blocking=True, + ) diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index a0cbdd17d75..475f38f554c 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -3,8 +3,9 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import Config +from airgradient import AirGradientConnectionError, AirGradientError, Config from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN @@ -16,6 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,3 +101,36 @@ async def test_cloud_creates_no_switch( await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@pytest.mark.parametrize( + ("exception", "error_message"), + [ + ( + AirGradientConnectionError("Something happened"), + "An error occurred while communicating with the Airgradient device: Something happened", + ), + ( + AirGradientError("Something else happened"), + "An unknown error occurred while communicating with the Airgradient device: Something else happened", + ), + ], +) +async def test_exception_handling( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error_message: str, +) -> None: + """Test exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.enable_sharing_data.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "switch.airgradient_post_data_to_airgradient"}, + blocking=True, + ) diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index ec501b2fd7e..1c760eaec52 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index 23a4d13cd00..134023f34e0 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -118,6 +120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -230,6 +234,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +292,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +348,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -399,6 +406,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,6 +464,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -511,6 +520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -568,6 +578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 3dd4788dc61..73ba6a7123f 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -35,6 +35,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index d70c1526510..09da6343e05 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,5 +1,6 @@ """Test the air-Q config flow.""" +import logging from unittest.mock import patch from aioairq import DeviceInfo, InvalidAuth @@ -37,8 +38,9 @@ DEFAULT_OPTIONS = { } -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test we get the form.""" + caplog.set_level(logging.DEBUG) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -54,6 +56,7 @@ async def test_form(hass: HomeAssistant) -> None: TEST_USER_DATA, ) await hass.async_block_till_done() + assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] diff --git a/tests/components/airq/test_coordinator.py b/tests/components/airq/test_coordinator.py new file mode 100644 index 00000000000..69f7c9dee17 --- /dev/null +++ b/tests/components/airq/test_coordinator.py @@ -0,0 +1,129 @@ +"""Test the air-Q coordinator.""" + +import logging +from unittest.mock import patch + +from aioairq import DeviceInfo as AirQDeviceInfo +import pytest + +from homeassistant.components.airq import AirQCoordinator +from homeassistant.components.airq.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") +MOCKED_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "192.168.0.0", + CONF_PASSWORD: "password", + }, + unique_id="123-456", +) + +TEST_DEVICE_INFO = AirQDeviceInfo( + id="id", + name="name", + model="model", + sw_version="sw", + hw_version="hw", +) +TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} +STATUS_WARMUP = { + "co": "co sensor still in warm up phase; waiting time = 18 s", + "tvoc": "tvoc sensor still in warm up phase; waiting time = 18 s", + "so2": "so2 sensor still in warm up phase; waiting time = 17 s", +} + + +async def test_logging_in_coordinator_first_update_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the first AirQCoordinator._async_update_data call logs necessary setup. + + The fields of AirQCoordinator.device_info that are specific to the device are only + populated upon the first call to AirQCoordinator._async_update_data. The one field + which is actually necessary is 'name', and its absence is checked and logged, + as well as its being set. + """ + caplog.set_level(logging.DEBUG) + coordinator = AirQCoordinator(hass, MOCKED_ENTRY) + + # check that the name _is_ missing + assert "name" not in coordinator.device_info + + # First call: fetch missing device info + with ( + patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), + patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), + ): + await coordinator._async_update_data() + + # check that the missing name is logged... + assert ( + "'name' not found in AirQCoordinator.device_info, fetching from the device" + in caplog.text + ) + # ...and fixed + assert coordinator.device_info.get("name") == TEST_DEVICE_INFO["name"] + assert ( + f"Updated AirQCoordinator.device_info for 'name' {TEST_DEVICE_INFO['name']}" + in caplog.text + ) + + # Also that no warming up sensors is found as none are mocked + assert "Following sensors are still warming up" not in caplog.text + + +async def test_logging_in_coordinator_subsequent_update_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the second AirQCoordinator._async_update_data call has nothing to log. + + The second call is emulated by setting up AirQCoordinator.device_info correctly, + instead of actually calling the _async_update_data, which would populate the log + with the messages we want to see not being repeated. + """ + caplog.set_level(logging.DEBUG) + coordinator = AirQCoordinator(hass, MOCKED_ENTRY) + coordinator.device_info.update(DeviceInfo(**TEST_DEVICE_INFO)) + + with ( + patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), + patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), + ): + await coordinator._async_update_data() + # check that the name _is not_ missing + assert "name" in coordinator.device_info + # and that nothing of the kind is logged + assert ( + "'name' not found in AirQCoordinator.device_info, fetching from the device" + not in caplog.text + ) + assert ( + f"Updated AirQCoordinator.device_info for 'name' {TEST_DEVICE_INFO['name']}" + not in caplog.text + ) + + +async def test_logging_when_warming_up_sensor_present( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that warming up sensors are logged.""" + caplog.set_level(logging.DEBUG) + coordinator = AirQCoordinator(hass, MOCKED_ENTRY) + with ( + patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), + patch( + "aioairq.AirQ.get_latest_data", + return_value=TEST_DEVICE_DATA | {"Status": STATUS_WARMUP}, + ), + ): + await coordinator._async_update_data() + assert ( + f"Following sensors are still warming up: {set(STATUS_WARMUP.keys())}" + in caplog.text + ) diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 79ae46500dd..314594c612f 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -7,7 +7,7 @@ from bleak import BleakError import pytest from homeassistant.components.airthings_ble.const import DOMAIN -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -153,6 +153,57 @@ async def test_user_setup(hass: HomeAssistant) -> None: assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" +async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + source=SOURCE_IGNORE, + data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}, + ) + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ), + patch_async_ble_device_from_address(WAVE_SERVICE_INFO), + patch_airthings_ble( + AirthingsDevice( + manufacturer="Airthings AS", + model=AirthingsDeviceType.WAVE_PLUS, + name="Airthings Wave Plus", + identifier="123456", + ) + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus" + } + + with patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"} + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave Plus (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + async def test_user_setup_no_device(hass: HomeAssistant) -> None: """Test the user initiated form without any device detected.""" with patch( diff --git a/tests/components/airtouch5/snapshots/test_cover.ambr b/tests/components/airtouch5/snapshots/test_cover.ambr index a8e57f69527..d2ae3cddc7f 100644 --- a/tests/components/airtouch5/snapshots/test_cover.ambr +++ b/tests/components/airtouch5/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index 606d6082351..0dbdef1d508 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index cb1d3a7aee7..113db6e3b96 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'XXXXXXX', 'version': 1, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index fb4f6530b1e..b4976c07e1b 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -140,6 +140,7 @@ 'heatStages': 1, 'heatangle': 0, 'humidity': 40, + 'master_zoneID': None, 'maxTemp': 30, 'minTemp': 15, 'mode': 3, @@ -274,6 +275,7 @@ 'config_entry': dict({ 'data': dict({ 'host': '192.168.1.100', + 'id': 0, 'port': 3000, }), 'disabled_by': None, @@ -281,12 +283,14 @@ }), 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 072699c7a26..65897c6da7e 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -12,7 +12,6 @@ from aioairzone.exceptions import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.airzone.config_flow import short_mac from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -20,6 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import ( CONFIG, @@ -28,11 +28,12 @@ from .util import ( HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, + USER_INPUT, ) from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="airzone", ip="192.168.1.100", macaddress=dr.format_mac("E84F25000000").replace(":", ""), @@ -81,7 +82,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG + result["flow_id"], USER_INPUT ) await hass.async_block_till_done() @@ -94,7 +95,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["title"] == f"Airzone {CONFIG[CONF_HOST]}:{CONFIG[CONF_PORT]}" assert result["data"][CONF_HOST] == CONFIG[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG[CONF_PORT] - assert CONF_ID not in result["data"] + assert result["data"][CONF_ID] == CONFIG[CONF_ID] assert len(mock_setup_entry.mock_calls) == 1 @@ -129,7 +130,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] is FlowResultType.FORM @@ -154,7 +155,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert ( result["title"] - == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]}" + == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]} #{CONFIG_ID1[CONF_ID]}" ) assert result["data"][CONF_HOST] == CONFIG_ID1[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG_ID1[CONF_PORT] @@ -167,6 +168,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: """Test setting up duplicated entry.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -174,7 +176,7 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] is FlowResultType.ABORT @@ -189,7 +191,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: side_effect=AirzoneError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index 583758a6bee..fcdcad6a32a 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -25,6 +25,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: """Test ClientConnectorError on coordinator update.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -74,6 +75,7 @@ async def test_coordinator_new_devices( """Test new devices on coordinator update.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 293fc75acb5..a2783cb7c2f 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -2,14 +2,16 @@ from unittest.mock import patch +from aioairzone.const import DEFAULT_SYSTEM_ID from aioairzone.exceptions import HotWaterNotAvailable, InvalidMethod, SystemOutOfRange from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, USER_INPUT from tests.common import MockConfigEntry @@ -19,7 +21,11 @@ async def test_unique_id_migrate( ) -> None: """Test unique id migration.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + config_entry = MockConfigEntry( + minor_version=2, + domain=DOMAIN, + data=CONFIG, + ) config_entry.add_to_hass(hass) with ( @@ -89,6 +95,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test unload.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, domain=DOMAIN, unique_id="airzone_unique_id", @@ -112,3 +119,42 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry_v2(hass: HomeAssistant) -> None: + """Test entry migration to v2.""" + + config_entry = MockConfigEntry( + minor_version=1, + data=USER_INPUT, + domain=DOMAIN, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + side_effect=SystemOutOfRange, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ), + patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + side_effect=InvalidMethod, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.minor_version == 2 + assert config_entry.data.get(CONF_ID) == DEFAULT_SYSTEM_ID diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 278663b7a97..50d1964924d 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -28,6 +28,7 @@ from aioairzone.const import ( API_HEAT_STAGES, API_HUMIDITY, API_MAC, + API_MASTER_ZONE_ID, API_MAX_TEMP, API_MIN_TEMP, API_MODE, @@ -54,6 +55,7 @@ from aioairzone.const import ( API_WS_AZ, API_WS_TYPE, API_ZONE_ID, + DEFAULT_SYSTEM_ID, ) from homeassistant.components.airzone.const import DOMAIN @@ -62,13 +64,18 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -CONFIG = { +USER_INPUT = { CONF_HOST: "192.168.1.100", CONF_PORT: 3000, } +CONFIG = { + **USER_INPUT, + CONF_ID: DEFAULT_SYSTEM_ID, +} + CONFIG_ID1 = { - **CONFIG, + **USER_INPUT, CONF_ID: 1, } @@ -214,6 +221,7 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_MASTER_ZONE_ID: None, }, ] }, @@ -357,6 +365,7 @@ async def async_init_integration( """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( + minor_version=2, data=CONFIG, entry_id="6e7a0798c1734ba81d26ced0e690eaec", domain=DOMAIN, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index c6ad36916bf..4bd7bfaccdd 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'installation1', 'version': 1, diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index b01af287b7b..b2ef0a722fd 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -1,7 +1,11 @@ """Tests for the Aladdin Connect integration.""" from homeassistant.components.aladdin_connect import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_aladdin_connect_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_aladdin_connect_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py index ddf67b27860..541644def38 100644 --- a/tests/components/alarm_control_panel/conftest.py +++ b/tests/components/alarm_control_panel/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel.const import CodeFormat from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, frame -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import MockAlarm @@ -194,7 +194,7 @@ async def setup_alarm_control_panel_platform_test_entity( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test alarm control panel platform via config entry.""" async_add_entities([entity]) diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 17a301ccdf1..3efacb80560 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 263fb69c883..4407775a582 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -28,9 +28,10 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service NAME = "alert_test" DONE_MESSAGE = "alert_gone" @@ -116,6 +117,35 @@ async def test_silence(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> assert hass.states.get(ENTITY_ID).state == STATE_ON +async def test_silence_can_acknowledge_false(hass: HomeAssistant) -> None: + """Test that attempting to silence an alert with can_acknowledge=False will not silence.""" + # Create copy of config where can_acknowledge is False + config = deepcopy(TEST_CONFIG) + config[DOMAIN][NAME]["can_acknowledge"] = False + + # Setup the alert component + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Ensure the alert is currently on + hass.states.async_set(ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Attempt to acknowledge + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # The state should still be ON because can_acknowledge=False + assert hass.states.get(ENTITY_ID).state == STATE_ON + + async def test_reset(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test resetting the alert.""" assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) @@ -338,6 +368,7 @@ async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -> None: """Test that the done message is reset when canceled.""" entity = alert.AlertEntity(hass, *TEST_NOACK) + entity.platform = MockEntityPlatform(hass) entity._cancel = lambda *args: None assert entity._send_done_message is False entity._send_done_message = True diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 0a8f5b874fa..6faabc924b4 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -16,10 +16,12 @@ from amberelectric.models.spike_status import SpikeStatus from dateutil import parser import pytest +from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME from homeassistant.components.amberelectric.coordinator import ( AmberUpdateCoordinator, normalize_descriptor, ) +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -33,6 +35,17 @@ from .helpers import ( generate_current_interval, ) +from tests.common import MockConfigEntry + +MOCKED_ENTRY = MockConfigEntry( + domain="amberelectric", + data={ + CONF_SITE_NAME: "mock_title", + CONF_API_TOKEN: "psk_0000000000000000", + CONF_SITE_ID: GENERAL_ONLY_SITE_ID, + }, +) + @pytest.fixture(name="current_price_api") def mock_api_current_price() -> Generator: @@ -101,7 +114,9 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) """Test fetching a site with only a general channel.""" current_price_api.get_current_prices.return_value = GENERAL_CHANNEL - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( @@ -130,7 +145,9 @@ async def test_fetch_no_general_site( """Test fetching a site with no general channel.""" current_price_api.get_current_prices.return_value = CONTROLLED_LOAD_CHANNEL - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) with pytest.raises(UpdateFailed): await data_service._async_update_data() @@ -143,7 +160,9 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> """Test that the old values are maintained if a second call fails.""" current_price_api.get_current_prices.return_value = GENERAL_CHANNEL - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( @@ -193,7 +212,7 @@ async def test_fetch_general_and_controlled_load_site( GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL ) data_service = AmberUpdateCoordinator( - hass, current_price_api, GENERAL_AND_CONTROLLED_SITE_ID + hass, MOCKED_ENTRY, current_price_api, GENERAL_AND_CONTROLLED_SITE_ID ) result = await data_service._async_update_data() @@ -233,7 +252,7 @@ async def test_fetch_general_and_feed_in_site( GENERAL_CHANNEL + FEED_IN_CHANNEL ) data_service = AmberUpdateCoordinator( - hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID + hass, MOCKED_ENTRY, current_price_api, GENERAL_AND_FEED_IN_SITE_ID ) result = await data_service._async_update_data() @@ -273,7 +292,9 @@ async def test_fetch_potential_spike( ] general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL current_price_api.get_current_prices.return_value = general_channel - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() assert result["grid"]["price_spike"] == "potential" @@ -288,6 +309,8 @@ async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None ] general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE current_price_api.get_current_prices.return_value = general_channel - data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID) + data_service = AmberUpdateCoordinator( + hass, MOCKED_ENTRY, current_price_api, GENERAL_ONLY_SITE_ID + ) result = await data_service._async_update_data() assert result["grid"]["price_spike"] == "spike" diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index fd48184ca0b..8637471cc60 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -238,6 +242,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +302,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +359,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -407,6 +414,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +466,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -517,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -576,6 +586,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -635,6 +646,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -691,6 +703,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -746,6 +759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -803,6 +817,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -821,7 +836,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'ambient_network', @@ -836,6 +851,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_direction', 'friendly_name': 'Station A Wind direction', 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'unit_of_measurement': '°', @@ -857,6 +873,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -916,6 +933,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -975,6 +993,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1034,6 +1053,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1093,6 +1113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1149,6 +1170,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1264,6 +1287,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1320,6 +1344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1374,6 +1399,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1425,6 +1451,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1484,6 +1511,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1543,6 +1571,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1602,6 +1631,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1658,6 +1688,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1713,6 +1744,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1770,6 +1802,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1788,7 +1821,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'ambient_network', @@ -1803,6 +1836,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_direction', 'friendly_name': 'Station C Wind direction', 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'unit_of_measurement': '°', @@ -1824,6 +1858,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1883,6 +1918,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1942,6 +1978,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2000,6 +2037,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2058,6 +2096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2113,6 +2152,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2168,6 +2208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2226,6 +2267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2281,6 +2323,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2336,6 +2379,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2394,6 +2438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2452,6 +2497,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2510,6 +2556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2565,6 +2612,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2619,6 +2667,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2675,6 +2724,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2693,7 +2743,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'ambient_network', @@ -2708,6 +2758,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_direction', 'friendly_name': 'Station D Wind direction', 'unit_of_measurement': '°', }), @@ -2728,6 +2779,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2786,6 +2838,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 2f90b09d39f..07db19101ab 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 6e11b344b0b..799738eb677 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/androidtv/test_remote.py b/tests/components/androidtv/test_remote.py index d18e08d4df8..5a52fe52b3a 100644 --- a/tests/components/androidtv/test_remote.py +++ b/tests/components/androidtv/test_remote.py @@ -99,9 +99,9 @@ async def test_services_remote(hass: HomeAssistant, config) -> None: "adb_shell", {ATTR_COMMAND: ["BACK", "test"], ATTR_NUM_REPEATS: 2}, [ - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", - f"input keyevent {KEYS["BACK"]}", + f"input keyevent {KEYS['BACK']}", "test", ], ) diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 02e15bca415..0968ea5acff 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.androidtv_remote.config_flow import ( APPS_NEW_ID, CONF_APP_DELETE, @@ -22,6 +21,7 @@ from homeassistant.components.androidtv_remote.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -444,7 +444,7 @@ async def test_zeroconf_flow_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -522,7 +522,7 @@ async def test_zeroconf_flow_cannot_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -573,7 +573,7 @@ async def test_zeroconf_flow_pairing_invalid_auth( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -657,7 +657,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -710,7 +710,7 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -743,7 +743,7 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(host), ip_addresses=[ip_address(host)], port=6466, @@ -787,7 +787,7 @@ async def test_zeroconf_flow_already_configured_zeroconf_has_multiple_invalid_ip result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.5"), ip_addresses=[ip_address("1.2.3.5"), ip_address(host)], port=6466, diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index ce6b98c480c..f8ab098cc09 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -1,7 +1,7 @@ """Tests helpers.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -43,9 +43,7 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index e4dd7cd00bb..de414019317 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,7 +1,8 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': '1234', 'response': IntentResponse( card=dict({ }), @@ -20,7 +21,7 @@ speech=dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Error preparing LLM API: API non-existing not found', + 'speech': 'Error preparing LLM API', }), }), speech_slots=dict({ diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index a5a025b00d0..5973d9a3ee8 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, ), patch( @@ -151,7 +151,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, side_effect=side_effect, ): diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 65ede877281..6c8244a59ba 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,26 +1,115 @@ """Tests for the Anthropic integration.""" +from collections.abc import AsyncGenerator +from typing import Any from unittest.mock import AsyncMock, Mock, patch from anthropic import RateLimitError -from anthropic.types import Message, TextBlock, ToolUseBlock, Usage +from anthropic.types import ( + InputJSONDelta, + Message, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RawMessageStreamEvent, + TextBlock, + TextDelta, + ToolUseBlock, + Usage, +) from freezegun import freeze_time from httpx import URL, Request, Response from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import trace from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component -from homeassistant.util import ulid +from homeassistant.util import ulid as ulid_util from tests.common import MockConfigEntry +async def stream_generator( + responses: list[RawMessageStreamEvent], +) -> AsyncGenerator[RawMessageStreamEvent]: + """Generate a response from the assistant.""" + for msg in responses: + yield msg + + +def create_messages( + content_blocks: list[RawMessageStreamEvent], +) -> list[RawMessageStreamEvent]: + """Create a stream of messages with the specified content blocks.""" + return [ + RawMessageStartEvent( + message=Message( + type="message", + id="msg_1234567890ABCDEFGHIJKLMN", + content=[], + role="assistant", + model="claude-3-5-sonnet-20240620", + usage=Usage(input_tokens=0, output_tokens=0), + ), + type="message_start", + ), + *content_blocks, + RawMessageStopEvent(type="message_stop"), + ] + + +def create_content_block( + index: int, text_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a text content block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=TextBlock(text="", type="text"), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=TextDelta(text=text_part, type="text_delta"), + index=index, + type="content_block_delta", + ) + for text_part in text_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_tool_use_block( + index: int, tool_id: str, tool_name: str, json_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a tool use content block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ToolUseBlock( + id=tool_id, name=tool_name, input={}, type="tool_use" + ), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"), + index=index, + type="content_block_delta", + ) + for json_part in json_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -38,9 +127,7 @@ async def test_entity( CONF_LLM_HASS_API: "assist", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) state = hass.states.get("conversation.claude") @@ -84,8 +171,11 @@ async def test_template_error( "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), + patch( + "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + ), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -116,22 +206,34 @@ async def test_template_variables( }, ) with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock ) as mock_create, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): + mock_create.return_value = stream_generator( + create_messages( + create_content_block( + 0, ["Okay, let", " me take care of that for you", "."] + ) + ) + ) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( hass, "hello", None, context, agent_id="conversation.claude" ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] - assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] + result.response.speech["plain"]["speech"] + == "Okay, let me take care of that for you." + ) + assert "The user name is Test User." in mock_create.call_args.kwargs["system"] + assert "The user id is 12345." in mock_create.call_args.kwargs["system"] async def test_conversation_agent( @@ -169,39 +271,26 @@ async def test_function_call( for message in messages: for content in message["content"]: if not isinstance(content, str) and content["type"] == "tool_use": - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock( - type="text", - text="I have successfully called the function", - ) - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + create_content_block( + 0, ["I have ", "successfully called ", "the function"] + ), + ) ) - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock(type="text", text="Certainly, calling it now!"), - ToolUseBlock( - type="tool_use", - id="toolu_0123456789AbCdEfGhIjKlM", - name="test_tool", - input={"param1": "test_value"}, - ), - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + [ + *create_content_block(0, ["Certainly, calling it now!"]), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"para', 'm1": "test_valu', 'e"}'], + ), + ] + ) ) with ( @@ -223,6 +312,10 @@ async def test_function_call( assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.speech["plain"]["speech"] + == "I have successfully called the function" + ) assert mock_create.mock_calls[1][2]["messages"][2] == { "role": "user", "content": [ @@ -236,6 +329,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -249,42 +343,6 @@ async def test_function_call( ), ) - # Test Conversation tracing - traces = trace.async_get_traces() - assert traces - last_trace = traces[-1].as_dict() - trace_events = last_trace.get("events", []) - assert [event["event_type"] for event in trace_events] == [ - trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, - trace.ConversationTraceEventType.TOOL_CALL, - ] - # AGENT_DETAIL event contains the raw prompt passed to the model - detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["system"] - assert "Today's date is 2024-06-03." in trace_events[1]["data"]["system"] - - # Call it again, make sure we have updated prompt - with ( - patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create, - freeze_time("2024-06-04 23:00:00"), - ): - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - ) - - assert "Today's date is 2024-06-04." in mock_create.mock_calls[1][2]["system"] - # Test old assert message not updated - assert "Today's date is 2024-06-03." in trace_events[1]["data"]["system"] - @patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") async def test_function_exception( @@ -311,39 +369,27 @@ async def test_function_exception( for message in messages: for content in message["content"]: if not isinstance(content, str) and content["type"] == "tool_use": - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock( - type="text", - text="There was an error calling the function", + return stream_generator( + create_messages( + create_content_block( + 0, + ["There was an error calling the function"], ) - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + ) ) - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock(type="text", text="Certainly, calling it now!"), - ToolUseBlock( - type="tool_use", - id="toolu_0123456789AbCdEfGhIjKlM", - name="test_tool", - input={"param1": "test_value"}, - ), - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + [ + *create_content_block(0, "Certainly, calling it now!"), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"param1": "test_value"}'], + ), + ] + ) ) with patch( @@ -360,6 +406,10 @@ async def test_function_exception( ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.speech["plain"]["speech"] + == "There was an error calling the function" + ) assert mock_create.mock_calls[1][2]["messages"][2] == { "role": "user", "content": [ @@ -373,6 +423,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -411,15 +462,10 @@ async def test_assist_api_tools_conversion( with patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock, - return_value=Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[TextBlock(type="text", text="Hello, how can I help you?")], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return_value=stream_generator( + create_messages( + create_content_block(0, "Hello, how can I help you?"), + ), ), ) as mock_create: await conversation.async_converse( @@ -444,44 +490,60 @@ async def test_unknown_hass_api( CONF_LLM_HASS_API: "non-existing", }, ) + await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", "1234", Context(), agent_id="conversation.claude" ) assert result == snapshot -@patch("anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock) async def test_conversation_id( - mock_create, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, ) -> None: """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id="conversation.claude" - ) - conversation_id = result.conversation_id + def create_stream_generator(*args, **kwargs) -> Any: + return stream_generator( + create_messages( + create_content_block(0, "Hello, how can I help you?"), + ), + ) - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id="conversation.claude" - ) + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=create_stream_generator, + ): + result = await conversation.async_converse( + hass, "hello", "1234", Context(), agent_id="conversation.claude" + ) - assert result.conversation_id == conversation_id + result = await conversation.async_converse( + hass, "hello", None, None, agent_id="conversation.claude" + ) - unknown_id = ulid.ulid() + conversation_id = result.conversation_id - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id="conversation.claude" - ) + result = await conversation.async_converse( + hass, "hello", conversation_id, None, agent_id="conversation.claude" + ) - assert result.conversation_id != unknown_id + assert result.conversation_id == conversation_id - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id="conversation.claude" - ) + unknown_id = ulid_util.ulid() - assert result.conversation_id == "koala" + result = await conversation.async_converse( + hass, "hello", unknown_id, None, agent_id="conversation.claude" + ) + + assert result.conversation_id != unknown_id + + result = await conversation.async_converse( + hass, "hello", "koala", None, agent_id="conversation.claude" + ) + + assert result.conversation_id == "koala" diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index ee87bb708d0..305e442f52d 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,6 +1,6 @@ """Tests for the Anthropic integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from anthropic import ( APIConnectionError, @@ -55,8 +55,7 @@ async def test_init_error( ) -> None: """Test initialization errors.""" with patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, + "anthropic.resources.models.AsyncModels.retrieve", side_effect=side_effect, ): assert await async_setup_component(hass, "anthropic", {}) diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index dec33a92fe2..e647b7fa6a5 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'basement', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr index 563b52f6df7..c422e8fdab5 100644 --- a/tests/components/aosmith/snapshots/test_sensor.ambr +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr index deb079570f1..43db89807b6 100644 --- a/tests/components/aosmith/snapshots/test_water_heater.ambr +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 95, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -71,6 +72,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 88594260579..0b8386dbb5a 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -125,6 +125,8 @@ async def test_flow_works(hass: HomeAssistant) -> None: ({"UPSNAME": "Friendly Name"}, "Friendly Name"), ({"MODEL": "MODEL X"}, "MODEL X"), ({"SERIALNO": "ZZZZ"}, "ZZZZ"), + # Some models report "Blank" as serial number, which we should treat it as not reported. + ({"SERIALNO": "Blank"}, "APC UPS"), ({}, "APC UPS"), ], ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 723ec164eae..6bb94ca2948 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -31,6 +31,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Does not contain either "SERIALNO" field. # We should _not_ create devices for the entities and their IDs will not have prefixes. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, ], ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: @@ -41,7 +43,7 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No await async_init_integration(hass, status=status) prefix = "" - if "SERIALNO" in status: + if "SERIALNO" in status and status["SERIALNO"] != "Blank": prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. @@ -56,6 +58,8 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No [ # We should not create device entries if SERIALNO is not reported. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, # We should set the device name to be the friendly UPSNAME field if available. MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, # Otherwise, we should fall back to default device name --- "APC UPS". @@ -71,7 +75,7 @@ async def test_device_entry( await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status: + if "SERIALNO" not in status or status["SERIALNO"] == "Blank": assert len(device_registry.devices) == 0 return diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index abce262fd12..6363304effc 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -11,10 +11,9 @@ from aiohttp.test_utils import TestClient import pytest import voluptuous as vol -from homeassistant import const +from homeassistant import const, core as ha from homeassistant.auth.models import Credentials from homeassistant.bootstrap import DATA_LOGGING -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 4567bd32582..a13eb3c605b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -9,7 +9,6 @@ from pyatv.const import PairingRequirement, Protocol import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.apple_tv import CONF_ADDRESS, config_flow from homeassistant.components.apple_tv.const import ( CONF_IDENTIFIERS, @@ -19,12 +18,13 @@ from homeassistant.components.apple_tv.const import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import airplay_service, create_conf, mrp_service, raop_service from tests.common import MockConfigEntry -DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( +DMAP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -35,7 +35,7 @@ DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( ) -RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( +RAOP_SERVICE = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -566,7 +566,7 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -586,7 +586,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: unrelated_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -601,7 +601,7 @@ async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -883,7 +883,7 @@ async def test_zeroconf_abort_if_other_in_progress( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -906,7 +906,7 @@ async def test_zeroconf_abort_if_other_in_progress( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -933,7 +933,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -955,7 +955,7 @@ async def test_zeroconf_missing_device_during_protocol_resolve( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -992,7 +992,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1014,7 +1014,7 @@ async def test_zeroconf_additional_protocol_resolve_failure( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1053,7 +1053,7 @@ async def test_zeroconf_pair_additionally_found_protocols( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1096,7 +1096,7 @@ async def test_zeroconf_pair_additionally_found_protocols( await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1158,7 +1158,7 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1242,7 +1242,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b72d9653c2d..9896e4c9fc0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -423,10 +423,7 @@ async def test_import_named_credential( ] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -436,10 +433,7 @@ async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_other_domain( hass: HomeAssistant, ws_client: ClientFixture, @@ -567,10 +561,7 @@ async def test_config_flow_multiple_entries( ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_create_delete_credential( hass: HomeAssistant, ws_client: ClientFixture, @@ -616,10 +607,7 @@ async def test_config_flow_with_config_credential( assert result["data"].get("auth_implementation") == TEST_DOMAIN -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_import_without_setup(hass: HomeAssistant, config_credential) -> None: """Test import of credentials without setting up the integration.""" @@ -635,10 +623,7 @@ async def test_import_without_setup(hass: HomeAssistant, config_credential) -> N assert result.get("reason") == "missing_configuration" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_websocket_without_platform( hass: HomeAssistant, ws_client: ClientFixture diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index 0875c88976b..381fc1864fc 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_number.ambr b/tests/components/apsystems/snapshots/test_number.ambr index a2b82e23596..21141de7d64 100644 --- a/tests/components/apsystems/snapshots/test_number.ambr +++ b/tests/components/apsystems/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_sensor.ambr b/tests/components/apsystems/snapshots/test_sensor.ambr index 669e89fda17..251a8d8428c 100644 --- a/tests/components/apsystems/snapshots/test_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_switch.ambr b/tests/components/apsystems/snapshots/test_switch.ambr index 6daa9fd6e14..a9f74ee5517 100644 --- a/tests/components/apsystems/snapshots/test_switch.ambr +++ b/tests/components/apsystems/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr index a237f59881a..eeac14c000d 100644 --- a/tests/components/aquacell/snapshots/test_sensor.ambr +++ b/tests/components/aquacell/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +157,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +206,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aranet/test_config_flow.py b/tests/components/aranet/test_config_flow.py index 9596507960b..c40725c397d 100644 --- a/tests/components/aranet/test_config_flow.py +++ b/tests/components/aranet/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.aranet.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -275,3 +276,31 @@ async def test_async_step_user_integrations_disabled(hass: HomeAssistant) -> Non ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "integrations_disabled" + + +async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", source=SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aranet.config_flow.async_discovered_service_info", + return_value=[VALID_DATA_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.aranet.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"address": "aa:bb:cc:dd:ee:ff"} + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Aranet4 12345" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 78a1d4aa9c9..a1a5ca32378 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -3,7 +3,7 @@ import pytest from homeassistant.components.aranet.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -170,7 +170,7 @@ async def test_sensors_aranet4( assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, VALID_DATA_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 + assert len(hass.states.async_all("sensor")) == 7 batt_sensor = hass.states.get("sensor.aranet4_12345_battery") batt_sensor_attrs = batt_sensor.attributes @@ -214,6 +214,12 @@ async def test_sensors_aranet4( assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + status_sensor = hass.states.get("sensor.aranet4_12345_threshold") + status_sensor_attrs = status_sensor.attributes + assert status_sensor.state == "green" + assert status_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet4 12345 Threshold" + assert status_sensor_attrs[ATTR_OPTIONS] == ["error", "green", "yellow", "red"] + # Check device context for the battery sensor entity = entity_registry.async_get("sensor.aranet4_12345_battery") device = device_registry.async_get(entity.device_id) @@ -245,7 +251,7 @@ async def test_sensors_aranetrn( assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, VALID_ARANET_RADON_DATA_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 + assert len(hass.states.async_all("sensor")) == 7 batt_sensor = hass.states.get("sensor.aranetrn_12345_battery") batt_sensor_attrs = batt_sensor.attributes @@ -291,6 +297,12 @@ async def test_sensors_aranetrn( assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + status_sensor = hass.states.get("sensor.aranetrn_12345_threshold") + status_sensor_attrs = status_sensor.attributes + assert status_sensor.state == "green" + assert status_sensor_attrs[ATTR_FRIENDLY_NAME] == "AranetRn+ 12345 Threshold" + assert status_sensor_attrs[ATTR_OPTIONS] == ["error", "green", "yellow", "red"] + # Check device context for the battery sensor entity = entity_registry.async_get("sensor.aranetrn_12345_battery") device = device_registry.async_get(entity.device_id) diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index 60c68c5e102..1a578fc613d 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -7,12 +7,21 @@ from unittest.mock import AsyncMock, MagicMock, patch from arcam.fmj.client import ConnectionFailed import pytest -from homeassistant.components import ssdp from homeassistant.components.arcam_fmj.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_CONFIG_ENTRY, @@ -36,18 +45,18 @@ MOCK_UPNP_DEVICE = f""" MOCK_UPNP_LOCATION = f"http://{MOCK_HOST}:8080/dd.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}:8080/dd.xml", upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "ARCAM", - ssdp.ATTR_UPNP_MODEL_NAME: " ", - ssdp.ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", - ssdp.ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", - ssdp.ATTR_UPNP_SERIAL: "12343", - ssdp.ATTR_UPNP_UDN: MOCK_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", + ATTR_UPNP_MANUFACTURER: "ARCAM", + ATTR_UPNP_MODEL_NAME: " ", + ATTR_UPNP_MODEL_NUMBER: "AVR450, AVR750", + ATTR_UPNP_FRIENDLY_NAME: f"Arcam media client {MOCK_UUID}", + ATTR_UPNP_SERIAL: "12343", + ATTR_UPNP_UDN: MOCK_UDN, + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:MediaRenderer:1", }, ) @@ -115,7 +124,7 @@ async def test_ssdp_unable_to_connect( async def test_ssdp_invalid_id(hass: HomeAssistant) -> None: """Test a ssdp with invalid UDN.""" discover = replace( - MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ssdp.ATTR_UPNP_UDN: "invalid"} + MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ATTR_UPNP_UDN: "invalid"} ) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr index 5c7888c41de..ed2494c3197 100644 --- a/tests/components/arve/snapshots/test_sensor.ambr +++ b/tests/components/arve/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -43,6 +44,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +80,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -148,6 +152,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +188,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +224,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 0f6872edbfe..a0549f27f05 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterable, Generator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -24,8 +24,8 @@ from homeassistant.components.assist_pipeline.pipeline import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import chat_session, device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from tests.common import ( @@ -225,7 +225,7 @@ async def init_supporting_components( async def async_setup_entry_stt_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test stt platform via config entry.""" async_add_entities([mock_stt_provider_entity]) @@ -233,7 +233,7 @@ async def init_supporting_components( async def async_setup_entry_wake_word_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test wake word platform via config entry.""" async_add_entities( @@ -325,7 +325,7 @@ async def assist_device( async def async_setup_entry_select_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test select platform via config entry.""" entities = [ @@ -379,3 +379,14 @@ def pipeline_storage(pipeline_data) -> PipelineStorageCollection: def make_10ms_chunk(header: bytes) -> bytes: """Return 10ms of zeros with the given header.""" return header + bytes(BYTES_PER_CHUNK - len(header)) + + +@pytest.fixture +def mock_chat_session(hass: HomeAssistant) -> Generator[chat_session.ChatSession]: + """Mock the ulid of chat sessions.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch("homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid"), + chat_session.async_get_chat_session(hass) as session, + ): + yield session diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f63a28efbb7..2375d48fcf9 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -3,8 +3,13 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -32,7 +37,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -44,7 +49,8 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -94,8 +100,13 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -123,7 +134,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -135,7 +146,8 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -185,8 +197,13 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -214,7 +231,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -226,7 +243,8 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -276,8 +294,13 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -329,7 +352,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -341,7 +364,8 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -391,8 +415,13 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), @@ -427,6 +456,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -434,7 +464,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -446,7 +476,114 @@ dict({ 'data': dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_stt_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-US', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + }), + }), + }), + 'processed_locally': True, + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- +# name: test_tts_language_used_instead_of_conversation_language + list([ + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': 'mock-ulid', + 'device_id': None, + 'engine': 'conversation.home_assistant', + 'intent_input': 'test input', + 'language': 'en-us', + 'prefer_local_intents': False, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': dict({ + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -478,8 +615,13 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 41747a50eb6..d937b5396d1 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -1,12 +1,17 @@ # serializer version: 1 # name: test_audio_pipeline dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline.1 @@ -31,7 +36,7 @@ # --- # name: test_audio_pipeline.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -42,7 +47,8 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -84,12 +90,17 @@ # --- # name: test_audio_pipeline_debug dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_debug.1 @@ -114,7 +125,7 @@ # --- # name: test_audio_pipeline_debug.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -125,7 +136,8 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -179,12 +191,17 @@ # --- # name: test_audio_pipeline_with_enhancements dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_enhancements.1 @@ -209,7 +226,7 @@ # --- # name: test_audio_pipeline_with_enhancements.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -220,7 +237,8 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -262,12 +280,17 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 @@ -314,7 +337,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.5 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -325,7 +348,8 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -367,12 +391,17 @@ # --- # name: test_audio_pipeline_with_wake_word_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_timeout.1 @@ -399,6 +428,7 @@ # --- # name: test_device_capture dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -425,6 +455,7 @@ # --- # name: test_device_capture_override dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -473,6 +504,7 @@ # --- # name: test_device_capture_queue_full dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -512,6 +544,7 @@ # --- # name: test_intent_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -522,7 +555,7 @@ # --- # name: test_intent_failed.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', @@ -535,6 +568,7 @@ # --- # name: test_intent_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -545,7 +579,7 @@ # --- # name: test_intent_timeout.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', @@ -564,17 +598,22 @@ # --- # name: test_pipeline_empty_tts_output dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_pipeline_empty_tts_output.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'never mind', @@ -585,7 +624,8 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -611,52 +651,77 @@ # --- # name: test_stt_cooldown_different_ids dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_different_ids.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed.1 @@ -677,6 +742,7 @@ # --- # name: test_text_only_pipeline[extra_msg0] dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -698,7 +764,8 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -710,7 +777,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), @@ -723,6 +790,7 @@ # --- # name: test_text_only_pipeline[extra_msg1] dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -744,7 +812,8 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -756,7 +825,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), @@ -773,45 +842,34 @@ 'message': 'Timeout running pipeline', }) # --- -# name: test_tts_failed - dict({ - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': None, - 'timeout': 300, - }), - }) -# --- -# name: test_tts_failed.1 - dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': 'Lights are on.', - 'voice': 'james_earl_jones', - }) -# --- -# name: test_tts_failed.2 - None -# --- # name: test_wake_word_cooldown_different_entities dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.2 @@ -857,22 +915,32 @@ # --- # name: test_wake_word_cooldown_different_ids dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.2 @@ -921,22 +989,32 @@ # --- # name: test_wake_word_cooldown_same_id dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.2 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index d4cce4e2e98..0e04d1f0cd2 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,11 +1,12 @@ """Test Voice Assistant init.""" import asyncio +from collections.abc import Generator from dataclasses import asdict import itertools as it from pathlib import Path import tempfile -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import wave import hass_nabucasa @@ -26,7 +27,7 @@ from homeassistant.components.assist_pipeline.const import ( ) from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component from .conftest import ( @@ -41,6 +42,22 @@ from .conftest import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(autouse=True) +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" processed = [] @@ -666,6 +683,7 @@ async def test_wake_word_detection_aborted( mock_wake_word_provider_entity: MockWakeWordEntity, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test creating a pipeline from an audio stream with wake word.""" @@ -684,7 +702,7 @@ async def test_wake_word_detection_aborted( pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id=None, + session=mock_chat_session, device_id=None, stt_metadata=stt.SpeechMetadata( language="", @@ -757,6 +775,7 @@ async def test_tts_audio_output( mock_tts_provider: MockTTSProvider, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test using tts_audio_output with wav sets options correctly.""" @@ -771,7 +790,7 @@ async def test_tts_audio_output( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -786,10 +805,16 @@ async def test_tts_audio_output( await pipeline_input.validate() # Verify TTS audio settings - assert pipeline_input.run.tts_options is not None - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 + assert pipeline_input.run.tts_stream.options is not None + assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) + == 16000 + ) + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) + == 1 + ) with patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio: await pipeline_input.execute() @@ -798,9 +823,7 @@ async def test_tts_audio_output( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) # Ensure that no unsupported options were passed in assert mock_get_tts_audio.called @@ -814,6 +837,7 @@ async def test_tts_wav_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -828,7 +852,7 @@ async def test_tts_wav_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -863,9 +887,7 @@ async def test_tts_wav_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] @@ -882,6 +904,7 @@ async def test_tts_dict_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -896,7 +919,7 @@ async def test_tts_dict_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -936,9 +959,7 @@ async def test_tts_dict_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] @@ -953,6 +974,7 @@ async def test_tts_dict_preferred_format( async def test_sentence_trigger_overrides_conversation_agent( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that sentence triggers are checked before a non-default conversation agent.""" @@ -982,6 +1004,7 @@ async def test_sentence_trigger_overrides_conversation_agent( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test trigger sentence", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1029,6 +1052,7 @@ async def test_sentence_trigger_overrides_conversation_agent( async def test_prefer_local_intents( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that the default agent is checked first when local intents are preferred.""" @@ -1059,6 +1083,7 @@ async def test_prefer_local_intents( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="I'd like to order a stout please", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1102,13 +1127,153 @@ async def test_prefer_local_intents( ) -async def test_pipeline_language_used_instead_of_conversation_language( +async def test_intent_continue_conversation( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that a conversation agent flagging continue conversation gets response.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + response = intent.IntentResponse("en") + response.async_set_speech("For how long?") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + continue_conversation=True, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[1]["intent_output"]["continue_conversation"] is True + + # Change conversation agent to default one and register sentence trigger that should not be called + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine=None + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Hello"], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + # Because we did continue conversation, it should respond to the test agent again. + events.clear() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Hello", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ) as mock_prepare: + await pipeline_input.validate() + + # It requested test agent even if that was not default agent. + assert mock_prepare.mock_calls[0][1][1] == "test-agent" + + response = intent.IntentResponse("en") + response.async_set_speech("Timer set for 20 minutes") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + # Snapshot will show it was still handled by the test agent and not default agent + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[0]["engine"] == "test-agent" + assert results[1]["intent_output"]["continue_conversation"] is False + + +async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: - """Test that the pipeline language is used when the conversation language is '*' (all languages).""" + """Test that the STT language is used first when the conversation language is '*' (all languages).""" client = await hass_ws_client(hass) events: list[assist_pipeline.PipelineEvent] = [] @@ -1136,6 +1301,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1165,7 +1331,159 @@ async def test_pipeline_language_used_instead_of_conversation_language( assert intent_start is not None - # Pipeline language (en) should be used instead of '*' + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.stt_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.stt_language + ) + + +async def test_tts_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": "en-us", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' + assert intent_start.data.get("language") == pipeline.tts_language + + # Check input to async_converse + mock_async_converse.assert_called_once() + assert ( + mock_async_converse.call_args_list[0].kwargs.get("language") + == pipeline.tts_language + ) + + +async def test_pipeline_language_used_instead_of_conversation_language( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_chat_session: chat_session.ChatSession, + snapshot: SnapshotAssertion, +) -> None: + """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" + client = await hass_ws_client(hass) + + events: list[assist_pipeline.PipelineEvent] = [] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": MATCH_ALL, + "language": "en", + "name": "test_name", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + msg = await client.receive_json() + assert msg["success"] + pipeline_id = msg["result"]["id"] + pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test input", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + intent.IntentResponse(pipeline.language) + ), + ) as mock_async_converse: + await pipeline_input.execute() + + # Check intent start event + assert process_events(events) == snapshot + intent_start: assist_pipeline.PipelineEvent | None = None + for event in events: + if event.type == assist_pipeline.PipelineEventType.INTENT_START: + intent_start = event + break + + assert intent_start is not None + + # STT language (en-US) should be used instead of '*' assert intent_start.data.get("language") == pipeline.language # Check input to async_converse diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index d52e2a762ee..a7f6fbf7553 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any from unittest.mock import ANY, patch +from hassil.recognize import Intent, IntentData, RecognizeResult import pytest from homeassistant.components import conversation @@ -16,6 +17,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( PipelineData, PipelineStorageCollection, PipelineStore, + _async_local_fallback_intent_filter, async_create_default_pipeline, async_get_pipeline, async_get_pipelines, @@ -23,6 +25,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_update_pipeline, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES @@ -657,3 +660,40 @@ async def test_migrate_after_load(hass: HomeAssistant) -> None: assert pipeline_updated.stt_engine == "stt.test" assert pipeline_updated.tts_engine == "tts.test" + + +def test_fallback_intent_filter() -> None: + """Test that we filter the right things.""" + assert ( + _async_local_fallback_intent_filter( + RecognizeResult( + intent=Intent(intent.INTENT_GET_STATE), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + ) + is True + ) + assert ( + _async_local_fallback_intent_filter( + RecognizeResult( + intent=Intent(intent.INTENT_NEVERMIND), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + ) + is True + ) + assert ( + _async_local_fallback_intent_filter( + RecognizeResult( + intent=Intent(intent.INTENT_TURN_ON), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + ) + is False + ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 5ce3b1020d0..fec34cb2496 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import MockConfigEntry, MockPlatform, mock_platform @@ -31,7 +31,7 @@ class SelectPlatform(MockPlatform): self, hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fake select platform.""" pipeline_entity = AssistPipelineSelect(hass, "test-domain", "test-prefix") diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index c1caf6f86a4..060c0dce660 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2,12 +2,14 @@ import asyncio import base64 +from collections.abc import Generator from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import conversation from homeassistant.components.assist_pipeline.const import ( DOMAIN, SAMPLE_CHANNELS, @@ -18,10 +20,12 @@ from homeassistant.components.assist_pipeline.pipeline import ( DeviceAudioQueue, Pipeline, PipelineData, + async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import chat_session, device_registry as dr from .conftest import ( BYTES_ONE_SECOND, @@ -35,6 +39,22 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True) +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + @pytest.mark.parametrize( "extra_msg", [ @@ -815,74 +835,6 @@ async def test_stt_stream_failed( assert msg["result"] == {"events": events} -async def test_tts_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test pipeline run with text-to-speech error.""" - events = [] - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=RuntimeError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts start - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "tts-failed" - events.append(msg["event"]) - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} - - async def test_tts_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -893,23 +845,22 @@ async def test_tts_provider_missing( """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) - with patch( - "homeassistant.components.tts.async_support_options", - side_effect=HomeAssistantError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) + pipelines = async_get_pipelines(hass) + await async_update_pipeline(hass, pipelines[0], tts_engine="unavailable") - # result - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "tts-not-supported" + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" async def test_tts_provider_bad_options( @@ -923,8 +874,8 @@ async def test_tts_provider_bad_options( client = await hass_ws_client(hass) with patch( - "homeassistant.components.tts.async_support_options", - return_value=False, + "homeassistant.components.tts.SpeechManager.process_options", + side_effect=HomeAssistantError("Language not supported"), ): await client.send_json_auto_id( { @@ -2718,3 +2669,62 @@ async def test_stt_cooldown_different_ids( # Both should start stt assert {event_type_1, event_type_2} == {"stt-start"} + + +async def test_intent_progress_event( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test intent-progress events from a pipeline are forwarded.""" + client = await hass_ws_client(hass) + + orig_converse = conversation.async_converse + expected_delta_events = [ + {"chat_log_delta": {"role": "assistant"}}, + {"chat_log_delta": {"content": "Hello"}}, + ] + + async def mock_delta_stream(): + """Mock delta stream.""" + for d in expected_delta_events: + yield d["chat_log_delta"] + + async def mock_converse(**kwargs): + """Mock converse method.""" + with ( + chat_session.async_get_chat_session( + kwargs["hass"], kwargs["conversation_id"] + ) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + async for _content in chat_log.async_add_delta_content_stream( + "", mock_delta_stream() + ): + pass + + return await orig_converse(**kwargs) + + with patch("homeassistant.components.conversation.async_converse", mock_converse): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + "conversation_id": "mock-conversation-id", + "device_id": "mock-device-id", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + events = [] + for _ in range(6): + msg = await client.receive_json() + if msg["event"]["type"] == "intent-progress": + events.append(msg["event"]["data"]) + + assert events == expected_delta_events diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 9e9bfd959e6..79e4061bacc 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -16,7 +16,9 @@ from homeassistant.components.assist_satellite import ( ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.setup import async_setup_component +from homeassistant.util.ulid import ulid_hex from tests.common import ( MockConfigEntry, @@ -38,11 +40,19 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" - _attr_name = "Test Entity" - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + _attr_tts_options = {"test-option": "test-value"} - def __init__(self) -> None: + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" + self._attr_unique_id = ulid_hex() + self._attr_device_info = DeviceInfo( + { + "name": name, + "identifiers": {(TEST_DOMAIN, self._attr_unique_id)}, + } + ) + self._attr_name = name + self._attr_supported_features = features self.events = [] self.announcements: list[AssistSatelliteAnnouncement] = [] self.config = AssistSatelliteConfiguration( @@ -59,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -79,11 +90,35 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append( + (self._conversation_id, self._extra_system_prompt, start_announcement) + ) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite() + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) + + +@pytest.fixture +def entity2() -> MockAssistSatellite: + """Mock a second Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity 2", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity_no_features() -> MockAssistSatellite: + """Mock a third Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity No features", 0) @pytest.fixture @@ -99,6 +134,8 @@ async def init_components( hass: HomeAssistant, config_entry: ConfigEntry, entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, ) -> None: """Initialize components.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -125,7 +162,9 @@ async def init_components( async_unload_entry=async_unload_entry_init, ), ) - setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True) + setup_test_component_platform( + hass, AS_DOMAIN, [entity, entity2, entity_no_features], from_config_entry=True + ) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 884ba36782c..42b4adf742c 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -1,7 +1,8 @@ """Test the Assist Satellite entity.""" import asyncio -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest @@ -25,11 +26,32 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture +def mock_chat_session_conversation_id() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-conversation-id" + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -63,8 +85,8 @@ async def test_entity_state( ) assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None - assert kwargs["device_id"] is None - assert kwargs["tts_audio_output"] is None + assert kwargs["device_id"] is entity.device_entry.id + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -163,21 +185,32 @@ async def test_new_pipeline_cancels_pipeline( ( {"message": "Hello"}, AssistSatelliteAnnouncement( - "Hello", "https://www.home-assistant.io/resolved.mp3", "tts" + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://bla", + media_id_source="tts", ), ), ( { "message": "Hello", - "media_id": "media-source://bla", + "media_id": "media-source://given", }, AssistSatelliteAnnouncement( - "Hello", "https://www.home-assistant.io/resolved.mp3", "media_id" + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", ), ), ( {"media_id": "http://example.com/bla.mp3"}, - AssistSatelliteAnnouncement("", "http://example.com/bla.mp3", "url"), + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + media_id_source="url", + ), ), ], ) @@ -189,24 +222,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -464,3 +485,159 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "mock-conversation-id", + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "mock-conversation-id", + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +@pytest.mark.usefixtures("mock_chat_session_conversation_id") +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + +async def test_wake_word_start_keeps_responding( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test entity state stays responding on wake word start event.""" + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == AssistSatelliteState.IDLE + + # Get into responding state + audio_stream = object() + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" + ) as mock_start_pipeline: + await entity.async_accept_pipeline_from_satellite( + audio_stream, start_stage=PipelineStage.TTS + ) + + assert mock_start_pipeline.called + kwargs = mock_start_pipeline.call_args[1] + event_callback = kwargs["event_callback"] + event_callback(PipelineEvent(PipelineEventType.TTS_START, {})) + + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.RESPONDING + + # Verify that starting a new wake word stream keeps the state + audio_stream = object() + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" + ) as mock_start_pipeline: + await entity.async_accept_pipeline_from_satellite( + audio_stream, start_stage=PipelineStage.WAKE_WORD + ) + + assert mock_start_pipeline.called + kwargs = mock_start_pipeline.call_args[1] + event_callback = kwargs["event_callback"] + event_callback(PipelineEvent(PipelineEventType.WAKE_WORD_START, {})) + + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.RESPONDING + + # Only return to idle once TTS is finished + entity.tts_response_finished() + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.IDLE diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py new file mode 100644 index 00000000000..9304229dbe3 --- /dev/null +++ b/tests/components/assist_satellite/test_intent.py @@ -0,0 +1,130 @@ +"""Test assist satellite intents.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_source import PlayMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from .conftest import TEST_DOMAIN, MockAssistSatellite + + +@pytest.fixture +def mock_tts(): + """Mock TTS service.""" + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + yield + + +async def test_broadcast_intent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test we can invoke a broadcast intent.""" + + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity", + "name": "Test Entity", + "type": intent.IntentResponseTargetType.ENTITY, + }, + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": {}, # response comes from intents + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 1 + assert len(entity_no_features.announcements) == 0 + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) + # Broadcast doesn't targets device that triggered it. + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": {}, # response comes from intents + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 2 + + +async def test_broadcast_intent_excluded_domains( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test that the broadcast intent filters out entities in excluded domains.""" + + # Exclude the "test" domain + with patch( + "homeassistant.components.assist_satellite.intent.EXCLUDED_DOMAINS", + new={TEST_DOMAIN}, + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [], # no satellites + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": {}, + } diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 257961a5b32..f0a8f02fc50 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -313,6 +313,37 @@ async def test_get_configuration( } +async def test_get_configuration_not_implemented( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test getting stub satellite configuration when the entity doesn't implement the method.""" + ws_client = await hass_ws_client(hass) + + with patch.object( + entity, "async_get_configuration", side_effect=NotImplementedError() + ): + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/get_configuration", + "entity_id": ENTITY_ID, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Stub configuration + assert msg["result"] == { + "active_wake_words": [], + "available_wake_words": [], + "max_active_wake_words": 1, + "pipeline_entity_id": None, + "vad_entity_id": None, + } + + async def test_set_wake_words( hass: HomeAssistant, init_components: ConfigEntry, diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 0036c40a6f2..929500f0bb7 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -82,6 +82,7 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): options={CONF_CONSIDER_HOME: 60}, unique_id=unique_id, ) + config_entry.add_to_hass(hass) # init variable obj_prefix = slugify(HOST) @@ -131,8 +132,6 @@ async def _test_sensors( disabled_by=None, ) - config_entry.add_to_hass(hass) - # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index 6e95b0ce552..be5947372f5 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.august.com', 'connections': set({ }), diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 6aad3a140ca..0a594fed1ee 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.august.com', 'connections': set({ tuple( diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 4ae300ae56b..bcdd4d55330 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index eb177a35cfb..065ffef91ff 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 4bc5a5d3086..0de8d923bb8 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -123,9 +123,9 @@ async def test_sensors(hass: HomeAssistant, entity_registry: EntityRegistry) -> ] for entity_id, _ in sensors: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/autarco/snapshots/test_sensor.ambr b/tests/components/autarco/snapshots/test_sensor.ambr index dbbd8e9b47d..d57f4be5da0 100644 --- a/tests/components/autarco/snapshots/test_sensor.ambr +++ b/tests/components/autarco/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -722,6 +736,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -773,6 +788,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index 1095c625fb2..1e7c616efeb 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, yaml +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service @@ -38,7 +38,7 @@ def patch_blueprint( return orig_load(self, path) return models.Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 98d8bf0396e..243e132dae2 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -51,8 +51,7 @@ from homeassistant.helpers.script import ( _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component -from homeassistant.util import yaml -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, @@ -1376,7 +1375,9 @@ async def test_reload_automation_when_blueprint_changes( # Reload the automations without any change, but with updated blueprint blueprint_path = automation.async_get_blueprints(hass).blueprint_folder - blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml") + blueprint_config = yaml_util.load_yaml( + blueprint_path / "test_event_service.yaml" + ) blueprint_config["actions"] = [blueprint_config["actions"]] blueprint_config["actions"].append(blueprint_config["actions"][-1]) @@ -1387,7 +1388,7 @@ async def test_reload_automation_when_blueprint_changes( return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml_dict", + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", autospec=True, return_value=blueprint_config, ), @@ -2691,7 +2692,7 @@ async def test_blueprint_automation_fails_substitution( """Test blueprint automation with bad inputs.""" with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): assert await async_setup_component( hass, diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index f24eaeb971d..6dc9118f511 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -2,15 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo AWAIR_UUID = "awair_24947" CLOUD_CONFIG = {CONF_ACCESS_TOKEN: "12345"} LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"} CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" -ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.5"), ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", diff --git a/tests/components/axis/snapshots/test_binary_sensor.ambr b/tests/components/axis/snapshots/test_binary_sensor.ambr index ab860489d55..6c0f3ead473 100644 --- a/tests/components/axis/snapshots/test_binary_sensor.ambr +++ b/tests/components/axis/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +486,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_camera.ambr b/tests/components/axis/snapshots/test_camera.ambr index 564ff96b3d8..1e70e2a799f 100644 --- a/tests/components/axis/snapshots/test_camera.ambr +++ b/tests/components/axis/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index ebd0061f416..b475c796d2b 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 16579287f09..9e407bfef0b 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -39,6 +40,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( diff --git a/tests/components/axis/snapshots/test_light.ambr b/tests/components/axis/snapshots/test_light.ambr index b37da39fe27..d8d01543ee5 100644 --- a/tests/components/axis/snapshots/test_light.ambr +++ b/tests/components/axis/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_switch.ambr b/tests/components/axis/snapshots/test_switch.ambr index dc4c75371cf..fa6091550e5 100644 --- a/tests/components/axis/snapshots/test_switch.ambr +++ b/tests/components/axis/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 6cc4bbd7c2f..9dcfbac4e7b 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -63,12 +63,12 @@ async def test_camera( assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" assert ( camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi" - f"{"" if not stream_profile else f"?{stream_profile}"}" + f"{'' if not stream_profile else f'?{stream_profile}'}" ) assert ( await camera_entity.stream_source() == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264" - f"{"" if not stream_profile else f"&{stream_profile}"}" + f"{'' if not stream_profile else f'&{stream_profile}'}" ) diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 52dd9c2f8ad..c7c3097aaaa 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.axis import config_flow from homeassistant.components.axis.const import ( CONF_STREAM_PROFILE, @@ -33,6 +32,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DEFAULT_HOST, MAC, MODEL, NAME @@ -268,7 +270,7 @@ async def test_reconfiguration_flow_update_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -276,7 +278,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -312,7 +314,7 @@ async def test_reconfiguration_flow_update_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], port=80, @@ -376,7 +378,7 @@ async def test_discovery_flow( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip=DEFAULT_HOST, macaddress=DHCP_FORMATTED_MAC, @@ -384,7 +386,7 @@ async def test_discovery_flow( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -396,7 +398,7 @@ async def test_discovery_flow( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(DEFAULT_HOST), ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", @@ -431,7 +433,7 @@ async def test_discovered_device_already_configured( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="2.3.4.5", macaddress=DHCP_FORMATTED_MAC, @@ -440,7 +442,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -453,7 +455,7 @@ async def test_discovered_device_already_configured( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", @@ -507,7 +509,7 @@ async def test_discovery_flow_updated_configuration( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="", ip="", macaddress=dr.format_mac("01234567890").replace(":", ""), @@ -515,7 +517,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -527,7 +529,7 @@ async def test_discovery_flow_updated_configuration( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -556,7 +558,7 @@ async def test_discovery_flow_ignore_non_axis_device( [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname=f"axis-{MAC}", ip="169.254.3.4", macaddress=DHCP_FORMATTED_MAC, @@ -564,7 +566,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_SSDP, - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -576,7 +578,7 @@ async def test_discovery_flow_ignore_non_axis_device( ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("169.254.3.4"), ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 74cdb0164cd..b2f2d15d989 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -11,13 +11,14 @@ import axis as axislib import pytest from syrupy import SnapshotAssertion -from homeassistant.components import axis, zeroconf +from homeassistant.components import axis from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import RtspEventMock, RtspStateType from .const import ( @@ -93,7 +94,7 @@ async def test_update_address( mock_requests("2.3.4.5") await hass.config_entries.flow.async_init( AXIS_DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("2.3.4.5"), ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index aa8d1d9e7e0..0b8f35497c6 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -250,6 +255,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +302,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -388,6 +396,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -435,6 +444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/azure_storage/__init__.py b/tests/components/azure_storage/__init__.py new file mode 100644 index 00000000000..bfd2e72d979 --- /dev/null +++ b/tests/components/azure_storage/__init__.py @@ -0,0 +1,14 @@ +"""Azure Storage integration tests.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the azure_storage integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/azure_storage/conftest.py b/tests/components/azure_storage/conftest.py new file mode 100644 index 00000000000..7c583ac391e --- /dev/null +++ b/tests/components/azure_storage/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for Azure Storage tests.""" + +from collections.abc import AsyncIterator, Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.const import DOMAIN + +from .const import BACKUP_METADATA, USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.azure_storage.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(autouse=True) +def mock_client() -> Generator[MagicMock]: + """Mock the Azure Storage client.""" + with ( + patch( + "homeassistant.components.azure_storage.config_flow.ContainerClient", + autospec=True, + ) as container_client, + patch( + "homeassistant.components.azure_storage.ContainerClient", + new=container_client, + ), + ): + client = container_client.return_value + client.exists.return_value = False + + async def async_list_blobs(): + yield BlobProperties(metadata=BACKUP_METADATA) + yield BlobProperties(metadata=BACKUP_METADATA) + + client.list_blobs.return_value = async_list_blobs() + + class MockStream: + async def chunks(self) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_blob.return_value = MockStream() + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="account/container1", + domain=DOMAIN, + data=USER_INPUT, + ) diff --git a/tests/components/azure_storage/const.py b/tests/components/azure_storage/const.py new file mode 100644 index 00000000000..4edb754f650 --- /dev/null +++ b/tests/components/azure_storage/const.py @@ -0,0 +1,36 @@ +"""Consts for Azure Storage tests.""" + +from json import dumps + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, +) +from homeassistant.components.backup import AgentBackup + +USER_INPUT = { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", +} + +TEST_BACKUP = AgentBackup( + addons=[], + backup_id="23e64aec", + date="2024-11-22T11:48:48.727189+01:00", + database_included=True, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="Core 2024.12.0.dev0", + protected=False, + size=34519040, +) + +BACKUP_METADATA = { + "metadata_version": "1", + "backup_id": "23e64aec", + "backup_metadata": dumps(TEST_BACKUP.as_dict()), +} diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py new file mode 100644 index 00000000000..7c5912a4981 --- /dev/null +++ b/tests/components/azure_storage/test_backup.py @@ -0,0 +1,319 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import ANY, Mock, patch + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.azure_storage.const import ( + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA, TEST_BACKUP + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "extra_metadata": {}, + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = TEST_BACKUP.backup_id + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "extra_metadata": {}, + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_get_backup_does_not_throw_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent get backup does not throw on a backup not found.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] is None + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_client.delete_blob.assert_called_once() + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "random", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_client.delete_blob.call_count == 0 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {TEST_BACKUP.backup_id}" in caplog.text + mock_client.upload_blob.assert_called_once_with( + name="Core_2024.12.0.dev0_2024-11-22_11.48_48727189.tar", + metadata=BACKUP_METADATA, + data=ANY, + length=ANY, + ) + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_client.download_blob.assert_called_once() + + +async def test_agents_error_on_download_not_found( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + + async def async_list_blobs( + metadata: dict[str, str], + ) -> AsyncGenerator[BlobProperties]: + yield BlobProperties(metadata=metadata) + + mock_client.list_blobs.side_effect = [ + async_list_blobs(BACKUP_METADATA), + async_list_blobs({}), + ] + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + assert mock_client.download_blob.call_count == 0 + + +async def test_error_during_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the error wrapper.""" + mock_client.delete_blob.side_effect = HttpResponseError("Failed to delete backup") + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + f"{DOMAIN}.{mock_config_entry.entry_id}": ( + "Error during backup operation in async_delete_backup: " + "Status None, message: Failed to delete backup" + ) + } + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py new file mode 100644 index 00000000000..67dc44f9f2c --- /dev/null +++ b/tests/components/azure_storage/test_config_flow.py @@ -0,0 +1,198 @@ +"""Test the Azure storage config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +import pytest + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def __async_start_flow( + hass: HomeAssistant, +) -> ConfigFlowResult: + """Initialize the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + +async def test_flow( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow.""" + mock_client.exists.return_value = False + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + (ResourceNotFoundError, {"base": "cannot_connect"}), + (ClientAuthenticationError, {CONF_STORAGE_ACCOUNT_KEY: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test config flow errors.""" + mock_client.exists.side_effect = exception + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == errors + + # fix and finish the test + mock_client.exists.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } + + +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works with an errors.""" + + await setup_integration(hass, mock_config_entry) + + mock_client.exists.side_effect = Exception() + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + # fix the error and finish the flow successfully + mock_client.exists.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reconfigure flow works.""" + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CONTAINER_NAME: "new_container"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_CONTAINER_NAME: "new_container", + } diff --git a/tests/components/azure_storage/test_init.py b/tests/components/azure_storage/test_init.py new file mode 100644 index 00000000000..ca725134737 --- /dev/null +++ b/tests/components/azure_storage/test_init.py @@ -0,0 +1,54 @@ +"""Test the Azure storage integration.""" + +from unittest.mock import MagicMock + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (ClientAuthenticationError, ConfigEntryState.SETUP_ERROR), + (HttpResponseError, ConfigEntryState.SETUP_RETRY), + (ResourceNotFoundError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test various setup errors.""" + mock_client.exists.side_effect = exception() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 4f456cc6d72..e41da5c1bad 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.backup import ( DOMAIN, @@ -13,14 +13,15 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentPlatformProtocol, + BackupNotFound, Folder, ) from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, mock_platform +from tests.common import mock_platform LOCAL_AGENT_ID = f"{DOMAIN}.local" @@ -29,7 +30,7 @@ TEST_BACKUP_ABC123 = AgentBackup( backup_id="abc123", database_included=True, date="1970-01-01T00:00:00.000Z", - extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, + extra_metadata={"instance_id": "our_uuid", "with_automatic_settings": True}, folders=[Folder.MEDIA, Folder.SHARE], homeassistant_included=True, homeassistant_version="2024.12.0", @@ -52,89 +53,80 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" -class BackupAgentTest(BackupAgent): - """Test backup agent.""" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i - domain = "test" - def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: - """Initialize the backup agent.""" - self.name = name - if backups is None: - backups = [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="abc123", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=13, - ) - ] +def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: + """Create a mock backup agent.""" - self._backup_data: bytearray | None = None - self._backups = {backup.backup_id: backup for backup in backups} + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + """Mock download.""" + if not await get_backup(backup_id): + raise BackupNotFound + return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) - async def async_download_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - return AsyncMock(spec_set=["__aiter__"]) + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + """Get a backup.""" + return next((b for b in backups if b.backup_id == backup_id), None) - async def async_upload_backup( - self, + async def upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + backups.append(backup) backup_stream = await open_stream() - self._backup_data = bytearray() + backup_data = bytearray() async for chunk in backup_stream: - self._backup_data += chunk + backup_data += chunk + backups_data[backup.backup_id] = backup_data - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return list(self._backups.values()) - - async def async_get_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AgentBackup | None: - """Return a backup.""" - return self._backups.get(backup_id) - - async def async_delete_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> None: - """Delete a backup file.""" + backups = backups or [] + backups_data: dict[str, bytes] = {} + mock_agent = Mock(spec=BackupAgent) + mock_agent.domain = TEST_DOMAIN + mock_agent.name = name + mock_agent.unique_id = name + type(mock_agent).agent_id = BackupAgent.agent_id + mock_agent.async_delete_backup = AsyncMock( + spec_set=[BackupAgent.async_delete_backup] + ) + mock_agent.async_download_backup = AsyncMock( + side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] + ) + mock_agent.async_get_backup = AsyncMock( + side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] + ) + mock_agent.async_list_backups = AsyncMock( + return_value=backups, spec_set=[BackupAgent.async_list_backups] + ) + mock_agent.async_upload_backup = AsyncMock( + side_effect=upload_backup, + spec_set=[BackupAgent.async_upload_backup], + ) + return mock_agent async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, - configuration: ConfigType | None = None, *, backups: dict[str, list[AgentBackup]] | None = None, remote_agents: list[str] | None = None, -) -> bool: +) -> dict[str, Mock]: """Set up the Backup integration.""" + backups = backups or {} + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( @@ -142,30 +134,34 @@ async def setup_backup_integration( ), ): remote_agents = remote_agents or [] - platform = Mock( - async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest(agent, []) for agent in remote_agents] - ), - spec_set=BackupAgentPlatformProtocol, - ) + remote_agents_dict = {} + for agent in remote_agents: + if not agent.startswith(f"{TEST_DOMAIN}."): + raise ValueError(f"Invalid agent_id: {agent}") + name = agent.partition(".")[2] + remote_agents_dict[agent] = mock_backup_agent(name, backups.get(agent)) + if remote_agents: + platform = Mock( + async_get_backup_agents=AsyncMock( + return_value=list(remote_agents_dict.values()) + ), + spec_set=BackupAgentPlatformProtocol, + ) + await setup_backup_platform(hass, domain=TEST_DOMAIN, platform=platform) - mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) - assert await async_setup_component(hass, TEST_DOMAIN, {}) - - result = await async_setup_component(hass, DOMAIN, configuration or {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - if not backups: - return result - for agent_id, agent_backups in backups.items(): - if with_hassio and agent_id == LOCAL_AGENT_ID: - continue - agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} - if agent_id == LOCAL_AGENT_ID: - agent._loaded_backups = True + if LOCAL_AGENT_ID not in backups or with_hassio: + return remote_agents_dict - return result + agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + + for backup in backups[LOCAL_AGENT_ID]: + await agent.async_upload_backup(open_stream=None, backup=backup) + agent._loaded_backups = True + + return remote_agents_dict async def setup_backup_platform( diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index ee855fb70f2..eb38399eb79 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -9,10 +9,23 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 + +from tests.common import get_fixture_path + + +@pytest.fixture(name="instance_id", autouse=True) +def instance_id_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock instance ID.""" + with patch( + "homeassistant.components.backup.manager.instance_id.async_get", + return_value="our_uuid", + ): + yield @pytest.fixture(name="mocked_json_bytes") @@ -35,10 +48,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob @@ -69,9 +86,10 @@ def mock_create_backup() -> Generator[AsyncMock]: """Mock manager create backup.""" mock_written_backup = MagicMock(spec_set=WrittenBackup) mock_written_backup.backup.backup_id = "abc123" + mock_written_backup.backup.protected = False mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() - fut = Future() + fut: Future[MagicMock] = Future() fut.set_result(mock_written_backup) with patch( "homeassistant.components.backup.CoreBackupReaderWriter.async_create_backup" @@ -113,3 +131,18 @@ def mock_backup_generation_fixture( ), ): yield + + +@pytest.fixture +def mock_backups() -> Generator[None]: + """Fixture to setup test backups.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import backup as core_backup + + class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self._backup_dir = get_fixture_path("test_backups", DOMAIN) + + with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): + yield diff --git a/tests/components/backup/fixtures/test_backups/2bcb3113.tar b/tests/components/backup/fixtures/test_backups/2bcb3113.tar new file mode 100644 index 00000000000..8a6556634f3 Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/2bcb3113.tar differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar new file mode 100644 index 00000000000..f3b2845d5eb Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted new file mode 100644 index 00000000000..c97533fc1af Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted differ diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f21de9d9fad..28ee9b834c1 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,13 +32,14 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -46,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -61,12 +62,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -76,13 +84,42 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -95,6 +132,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -112,6 +150,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -124,6 +166,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -141,6 +184,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -153,6 +200,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -170,6 +218,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -182,6 +234,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -199,6 +252,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr new file mode 100644 index 00000000000..41778322825 --- /dev/null +++ b/tests/components/backup/snapshots/test_store.ambr @@ -0,0 +1,474 @@ +# serializer version: 1 +# name: test_store_migration[store_data0] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data0].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data2] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + 'something_from_the_future': 'value', + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data2].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 98b2f764d43..17e3ca8b176 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -16,10 +16,12 @@ 'result': dict({ 'agents': list([ dict({ - 'agent_id': 'backup.local', + 'agent_id': 'test.remote', + 'name': 'remote', }), dict({ - 'agent_id': 'domain.test', + 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -175,11 +177,88 @@ 'type': 'result', }) # --- -# name: test_config_info[None] +# name: test_can_decrypt_on_download[backup.local-2bcb3113-hunter2] + dict({ + 'error': dict({ + 'code': 'decrypt_not_supported', + 'message': 'Decrypt on download not supported', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-c0cb53bd-hunter2] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-c0cb53bd-wrong_password] + dict({ + 'error': dict({ + 'code': 'password_incorrect', + 'message': 'Incorrect password', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-no_such_backup-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup no_such_backup not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[no_such_agent-c0cb53bd-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected: no_such_agent', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Unknown error', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download_with_agent_error[BackupNotFound] + dict({ + 'error': dict({ + 'code': 'backup_not_found', + 'message': 'Backup not found', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[with_hassio-storage_data0] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -192,12 +271,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -205,11 +289,14 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data1] +# name: test_config_load_config_info[with_hassio-storage_data1] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -227,12 +314,24 @@ }), 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, }), }), }), @@ -240,11 +339,14 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data2] +# name: test_config_load_config_info[with_hassio-storage_data2] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -258,12 +360,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -271,11 +378,14 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data3] +# name: test_config_load_config_info[with_hassio-storage_data3] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -289,12 +399,17 @@ }), 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -302,11 +417,14 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data4] +# name: test_config_load_config_info[with_hassio-storage_data4] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -320,12 +438,18 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'mon', + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, }), }), }), @@ -333,11 +457,14 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data5] +# name: test_config_load_config_info[with_hassio-storage_data5] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -351,12 +478,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'sat', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -364,11 +496,183 @@ 'type': 'result', }) # --- -# name: test_config_update[command0] +# name: test_config_load_config_info[with_hassio-storage_data6] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[with_hassio-storage_data7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[with_hassio-storage_data8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'hassio.local', + 'hassio.share', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[with_hassio-storage_data9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'hassio.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -381,12 +685,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -394,11 +703,463 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].1 +# name: test_config_load_config_info[without_hassio-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands0].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands0].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -417,7 +1178,50 @@ 'days': 7, }), 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -425,12 +1229,171 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].2 +# name: test_config_update[commands10].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands10].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands11] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands11].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands11].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -449,20 +1412,27 @@ 'days': 7, }), 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- -# name: test_config_update[command10] +# name: test_config_update[commands12] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -475,12 +1445,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -488,14 +1463,22 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].1 +# name: test_config_update[commands12].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -506,12 +1489,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, - 'days': 7, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -519,15 +1507,23 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].2 +# name: test_config_update[commands12].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -540,23 +1536,30 @@ 'last_completed_automatic_backup': None, 'retention': dict({ 'copies': None, - 'days': 7, + 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- -# name: test_config_update[command1] +# name: test_config_update[commands13] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -569,12 +1572,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -582,14 +1590,22 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].1 +# name: test_config_update[commands13].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -600,12 +1616,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -613,15 +1634,67 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].2 +# name: test_config_update[commands13].2 + dict({ + 'id': 5, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].3 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -637,20 +1710,27 @@ 'days': None, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- -# name: test_config_update[command2] +# name: test_config_update[commands14] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -663,12 +1743,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -676,14 +1761,16 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].1 +# name: test_config_update[commands14].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -694,12 +1781,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'mon', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -707,15 +1799,17 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].2 +# name: test_config_update[commands14].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -731,20 +1825,27 @@ 'days': None, }), 'schedule': dict({ - 'state': 'mon', + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- -# name: test_config_update[command3] +# name: test_config_update[commands15] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -757,12 +1858,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -770,14 +1876,16 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].1 +# name: test_config_update[commands15].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ - 'test-agent', ]), 'include_addons': None, 'include_all_addons': False, @@ -788,12 +1896,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -801,12 +1914,131 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].2 +# name: test_config_update[commands15].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T06:00:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': '06:00:00', + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands1].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -825,20 +2057,27 @@ 'days': None, }), 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', 'state': 'never', + 'time': '06:00:00', }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- -# name: test_config_update[command4] +# name: test_config_update[commands2] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -851,12 +2090,136 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands2].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands2].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -864,11 +2227,296 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].1 +# name: test_config_update[commands3].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands3].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands4].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': list([ + 'test-addon', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + ]), + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands5].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -891,56 +2539,27 @@ 'days': None, }), 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command4].2 - dict({ - 'data': dict({ - 'backups': list([ - ]), - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', + 'days': list([ ]), - 'include_addons': list([ - 'test-addon', - ]), - 'include_all_addons': False, - 'include_database': True, - 'include_folders': list([ - 'media', - ]), - 'name': 'test-name', - 'password': 'test-password', - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', + 'recurrence': 'daily', + 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- -# name: test_config_update[command5] +# name: test_config_update[commands6] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -953,12 +2572,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -966,11 +2590,54 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].1 +# name: test_config_update[commands6].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands6].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -989,7 +2656,50 @@ 'days': 7, }), 'schedule': dict({ - 'state': 'daily', + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -997,12 +2707,171 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].2 +# name: test_config_update[commands7].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands7].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands8].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands8].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1018,41 +2887,92 @@ 'last_completed_automatic_backup': None, 'retention': dict({ 'copies': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'daily', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, 'days': 7, }), 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'key': 'backup', - 'minor_version': 1, - 'version': 1, - }) -# --- -# name: test_config_update[command6] - dict({ - 'id': 1, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ + 'days': list([ ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ - 'state': 'never', + 'recurrence': 'daily', + 'time': None, }), }), }), @@ -1060,199 +2980,15 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].1 - dict({ - 'id': 3, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command6].2 +# name: test_config_update[commands9].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, + 'agents': dict({ }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'key': 'backup', - 'minor_version': 1, - 'version': 1, - }) -# --- -# name: test_config_update[command7] - dict({ - 'id': 1, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ - 'state': 'never', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command7].1 - dict({ - 'id': 3, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': 3, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command7].2 - dict({ - 'data': dict({ - 'backups': list([ - ]), - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': 3, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'key': 'backup', - 'minor_version': 1, - 'version': 1, - }) -# --- -# name: test_config_update[command8] - dict({ - 'id': 1, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ - 'state': 'never', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command8].1 - dict({ - 'id': 3, - 'result': dict({ - 'config': dict({ + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1271,138 +3007,16 @@ 'days': 7, }), 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command8].2 - dict({ - 'data': dict({ - 'backups': list([ - ]), - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', + 'days': list([ ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': 7, - }), - 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'key': 'backup', - 'minor_version': 1, - 'version': 1, - }) -# --- -# name: test_config_update[command9] - dict({ - 'id': 1, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': None, - 'days': None, - }), - 'schedule': dict({ + 'recurrence': 'daily', 'state': 'never', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command9].1 - dict({ - 'id': 3, - 'result': dict({ - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': 3, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', - }), - }), - }), - 'success': True, - 'type': 'result', - }) -# --- -# name: test_config_update[command9].2 - dict({ - 'data': dict({ - 'backups': list([ - ]), - 'config': dict({ - 'create_backup': dict({ - 'agent_ids': list([ - 'test-agent', - ]), - 'include_addons': None, - 'include_all_addons': False, - 'include_database': True, - 'include_folders': None, - 'name': None, - 'password': None, - }), - 'last_attempted_automatic_backup': None, - 'last_completed_automatic_backup': None, - 'retention': dict({ - 'copies': 3, - 'days': None, - }), - 'schedule': dict({ - 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 5, 'version': 1, }) # --- @@ -1411,6 +3025,9 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1423,12 +3040,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1441,6 +3063,9 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1453,12 +3078,169 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command10].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command11] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command11].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1471,6 +3253,9 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1483,12 +3268,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1501,6 +3291,9 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1513,12 +3306,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1531,6 +3329,9 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1543,12 +3344,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1561,6 +3367,9 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1573,12 +3382,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1591,6 +3405,9 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1603,12 +3420,17 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1621,6 +3443,9 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1633,12 +3458,473 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ - 'state': 'never', + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command6].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command7].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command8].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, }), }), }), @@ -1656,6 +3942,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1682,6 +3972,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1702,12 +3996,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -1717,13 +4018,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1750,6 +4053,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1770,12 +4077,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -1785,13 +4099,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1823,12 +4139,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -1838,13 +4161,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1860,12 +4185,19 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -1875,13 +4207,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1908,12 +4242,19 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -1923,13 +4264,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -1950,13 +4293,23 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -1966,13 +4319,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2004,12 +4359,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2019,13 +4381,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2036,7 +4400,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), }), 'success': True, @@ -2058,12 +4422,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2073,13 +4444,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2090,7 +4463,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), }), 'success': True, @@ -2112,12 +4485,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ 'test.remote', ]), @@ -2128,13 +4508,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2166,12 +4548,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2181,13 +4570,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2219,12 +4610,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2234,13 +4632,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2248,12 +4648,13 @@ # --- # name: test_delete_with_errors[side_effect1-storage_data0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'test.remote': 'Boom!', + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -2272,12 +4673,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2287,13 +4695,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2301,12 +4711,13 @@ # --- # name: test_delete_with_errors[side_effect1-storage_data1] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'test.remote': 'Boom!', + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -2325,12 +4736,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ 'test.remote', ]), @@ -2341,13 +4759,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2379,12 +4799,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2394,8 +4821,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -2417,12 +4842,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2432,8 +4864,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -2467,13 +4897,23 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2483,8 +4923,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -2497,7 +4935,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), 'backup': dict({ 'addons': list([ @@ -2507,12 +4945,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2522,8 +4967,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -2533,12 +4976,89 @@ # --- # name: test_details_with_errors[side_effect0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'test.remote': 'Oops', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'test.remote': 'Boom!', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -2563,6 +5083,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -2584,6 +5105,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -2595,6 +5117,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -2606,6 +5129,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -2634,6 +5158,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -2655,6 +5180,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -2666,6 +5192,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -2677,6 +5204,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -2705,6 +5233,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -2726,6 +5255,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -2737,6 +5267,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -2748,6 +5279,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -2770,12 +5302,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2785,13 +5324,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2812,12 +5353,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2827,13 +5375,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2854,13 +5404,23 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2870,13 +5430,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2892,12 +5454,19 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2907,8 +5476,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), dict({ @@ -2919,12 +5486,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2934,13 +5508,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2951,7 +5527,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), 'backups': list([ dict({ @@ -2962,12 +5538,19 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), 'failed_agent_ids': list([ ]), 'folders': list([ @@ -2977,13 +5560,15 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -2991,12 +5576,105 @@ # --- # name: test_info_with_errors[side_effect0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'test.remote': 'Oops', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'test.remote': 'Boom!', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + ]), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'state': 'idle', + }), + 'success': True, 'type': 'result', }) # --- @@ -3082,6 +5760,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3089,3 +5768,20 @@ 'type': 'event', }) # --- +# name: test_subscribe_event_early + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_subscribe_event_early.1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..c9d797f4e30 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,36 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +49,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -49,6 +64,7 @@ async def test_load_backups( side_effect: Exception | None, ) -> None: """Test load backups.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -68,6 +84,7 @@ async def test_upload( hass_client: ClientSessionGenerator, ) -> None: """Test upload backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -89,16 +106,26 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + assert move_mock.mock_calls[0].args[1].name == "Test_1970-01-01_00.00_00000000.tar" @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,22 +135,25 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index c071a0d8386..a03217beac2 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,20 +1,45 @@ """Tests for the Backup integration.""" import asyncio -from io import StringIO +from collections.abc import AsyncIterator +from io import BytesIO, StringIO +import json +import tarfile +from typing import Any from unittest.mock import patch from aiohttp import web import pytest -from homeassistant.components.backup.const import DATA_MANAGER +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgentError, + BackupNotFound, + Folder, +) +from homeassistant.components.backup.const import DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import TEST_BACKUP_ABC123, aiter_from_iter, setup_backup_integration -from tests.common import MockUser +from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator +PROTECTED_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="c0cb53bd", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, +) + async def test_downloading_local_backup( hass: HomeAssistant, @@ -30,6 +55,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -45,18 +73,152 @@ async def test_downloading_remote_backup( hass_client: ClientSessionGenerator, ) -> None: """Test downloading a remote backup.""" - await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + + await setup_backup_integration( + hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test.test"] + ) client = await hass_client() + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_downloading_local_encrypted_backup_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + client = await hass_client() + with ( - patch.object(BackupAgentTest, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - resp = await client.get("/api/backup/download/abc123?agent_id=domain.test") - assert resp.status == 200 - assert await resp.content.read() == b"backup data" + resp = await client.get( + "/api/backup/download/abc123?agent_id=backup.local&password=blah" + ) + assert resp.status == 404 + + +@pytest.mark.usefixtures("mock_backups") +async def test_downloading_local_encrypted_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + await setup_backup_integration(hass) + await _test_downloading_encrypted_backup(hass_client, "backup.local") + + +async def test_downloading_remote_encrypted_backup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test downloading a local backup file.""" + backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]} + ) + + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + return aiter_from_iter((backup_path.read_bytes(),)) + + mock_agents["test.test"].async_download_backup.side_effect = download_backup + await _test_downloading_encrypted_backup(hass_client, "test.test") + + +@pytest.mark.parametrize( + ("error", "status"), + [ + (BackupAgentError, 500), + (BackupNotFound, 404), + ], +) +async def test_downloading_remote_encrypted_backup_with_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + error: Exception, + status: int, +) -> None: + """Test downloading a local backup file.""" + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]} + ) + + mock_agents["test.test"].async_download_backup.side_effect = error + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{PROTECTED_BACKUP.backup_id}?agent_id=test.test&password=blah" + ) + assert resp.status == status + + +async def _test_downloading_encrypted_backup( + hass_client: ClientSessionGenerator, + agent_id: str, +) -> None: + """Test downloading an encrypted backup file.""" + # Try downloading without supplying a password + client = await hass_client() + resp = await client.get(f"/api/backup/download/c0cb53bd?agent_id={agent_id}") + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, but the inner tar file is encrypted and + # can't be read + with tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar: + enc_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert enc_metadata["protected"] is True + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + pytest.raises(tarfile.ReadError, match="file could not be opened"), + ): + # pylint: disable-next=consider-using-with + tarfile.open(fileobj=inner_tar_file, mode="r") + + # Download with the wrong password + resp = await client.get( + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=wrong" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a truncated outer tar file + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + pytest.raises(tarfile.ReadError, match="unexpected end of data"), + ): + outer_tar.getnames() + + # Finally download with the correct password + resp = await client.get( + f"/api/backup/download/c0cb53bd?agent_id={agent_id}&password=hunter2" + ) + assert resp.status == 200 + backup = await resp.read() + # We expect a valid outer tar file, the inner tar file is decrypted and can be read + with ( + tarfile.open(fileobj=BytesIO(backup), mode="r") as outer_tar, + ): + dec_metadata = json.loads(outer_tar.extractfile("./backup.json").read()) + assert dec_metadata == enc_metadata | {"protected": False} + with ( + outer_tar.extractfile("core.tar.gz") as inner_tar_file, + tarfile.open(fileobj=inner_tar_file, mode="r") as inner_tar, + ): + assert inner_tar.getnames() == [ + ".", + "README.md", + "test_symlink", + "test1", + "test1/script.sh", + ] async def test_downloading_backup_not_found( @@ -98,12 +260,14 @@ async def test_uploading_a_backup_file( with patch( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value=TEST_BACKUP_ABC123.backup_id, ) as async_receive_backup_mock: resp = await client.post( "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 + assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id} assert async_receive_backup_mock.called diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 16a49af9647..8a0cc2b97c0 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -11,6 +11,8 @@ from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +from tests.typing import WebSocketGenerator + @pytest.mark.usefixtures("supervisor_client") async def test_setup_with_hassio( @@ -18,11 +20,7 @@ async def test_setup_with_hassio( caplog: pytest.LogCaptureFixture, ) -> None: """Test the setup of the integration with hassio enabled.""" - assert await setup_backup_integration( - hass=hass, - with_hassio=True, - configuration={DOMAIN: {}}, - ) + await setup_backup_integration(hass=hass, with_hassio=True) manager = hass.data[DATA_MANAGER] assert not manager.backup_agents @@ -45,12 +43,101 @@ async def test_create_service( service_data=service_data, ) - assert generate_backup.called + generate_backup.assert_called_once_with( + agent_ids=["backup.local"], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) +@pytest.mark.usefixtures("supervisor_client") async def test_create_service_with_hassio(hass: HomeAssistant) -> None: """Test action backup.create does not exist with hassio.""" await setup_backup_integration(hass, with_hassio=True) with pytest.raises(ServiceNotFound): await hass.services.async_call(DOMAIN, "create", blocking=True) + + +@pytest.mark.parametrize( + ("commands", "expected_kwargs"), + [ + ( + [], + { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +@pytest.mark.parametrize("service_data", [None, {}]) +@pytest.mark.parametrize("with_hassio", [True, False]) +@pytest.mark.usefixtures("supervisor_client") +async def test_create_automatic_service( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + commands: list[dict[str, Any]], + expected_kwargs: dict[str, Any], + service_data: dict[str, Any] | None, + with_hassio: bool, +) -> None: + """Test generate backup.""" + await setup_backup_integration(hass, with_hassio=with_hassio) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as generate_backup: + await hass.services.async_call( + DOMAIN, + "create_automatic", + blocking=True, + service_data=service_data, + ) + + generate_backup.assert_called_once_with(**expected_kwargs) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index ad90e2e23bf..e4762f35327 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3,48 +3,62 @@ from __future__ import annotations import asyncio -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import replace from io import StringIO import json from pathlib import Path +import re +import tarfile from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, mock_open, patch +from unittest.mock import ( + ANY, + DEFAULT, + AsyncMock, + MagicMock, + Mock, + call, + mock_open, + patch, +) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( DOMAIN, AgentBackup, - BackupAgentPlatformProtocol, - BackupManager, BackupReaderWriterError, Folder, LocalBackupAgent, - backup as local_backup_platform, ) from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, + BackupManagerExceptionGroup, BackupManagerState, - CoreBackupReaderWriter, - CreateBackupEvent, CreateBackupStage, CreateBackupState, NewBackup, + ReceiveBackupStage, + ReceiveBackupState, + RestoreBackupState, WrittenBackup, ) +from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, - BackupAgentTest, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, + mock_backup_agent, + setup_backup_integration, setup_backup_platform, ) @@ -79,16 +93,24 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") -async def test_async_create_backup( +async def test_create_backup_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mocked_json_bytes: Mock, mocked_tarfile: Mock, ) -> None: - """Test create backup.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + """Test create backup service.""" + await setup_backup_integration(hass) new_backup = NewBackup(backup_job_id="time-123") backup_task = AsyncMock( @@ -114,7 +136,7 @@ async def test_async_create_backup( agent_ids=["backup.local"], backup_name="Custom backup 2025.1.0", extra_metadata={ - "instance_id": hass.data["core.uuid"], + "instance_id": "our_uuid", "with_automatic_settings": False, }, include_addons=None, @@ -127,30 +149,222 @@ async def test_async_create_backup( ) -async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: - """Test generate backup.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - manager.last_event = CreateBackupEvent( - stage=None, state=CreateBackupState.IN_PROGRESS +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("manager_kwargs", "expected_writer_kwargs"), + [ + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Automatic backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": "user defined name", + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "user defined name", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": " ", # Name which is just whitespace + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ], +) +async def test_async_create_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + manager_kwargs: dict[str, Any], + expected_writer_kwargs: dict[str, Any], +) -> None: + """Test create backup.""" + await setup_backup_integration(hass) + manager = hass.data[DATA_MANAGER] + + new_backup = NewBackup(backup_job_id="time-123") + backup_task = AsyncMock( + return_value=WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ), + )() # call it so that it can be awaited + + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await manager.async_create_backup(**manager_kwargs) + + assert create_backup.called + assert create_backup.call_args == call(**expected_writer_kwargs) + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_create_backup_when_busy( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test generate backup with busy manager.""" + await setup_backup_integration(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} ) - with pytest.raises(HomeAssistantError, match="Backup manager busy"): - await manager.async_create_backup( - agent_ids=[LOCAL_AGENT_ID], - include_addons=[], - include_all_addons=False, - include_database=True, - include_folders=[], - include_homeassistant=True, - name=None, - password=None, - ) + result = await ws_client.receive_json() + + assert result["success"] is True + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Backup manager busy: create_backup" @pytest.mark.parametrize( ("parameters", "expected_error"), [ - ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"agent_ids": []}, + "At least one available backup agent must be selected, got []", + ), + ( + {"agent_ids": ["non_existing"]}, + "At least one available backup agent must be selected, got ['non_existing']", + ), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", @@ -168,8 +382,7 @@ async def test_create_backup_wrong_parameters( expected_error: str, ) -> None: """Test create backup with wrong parameters.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) @@ -194,26 +407,103 @@ async def test_create_backup_wrong_parameters( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + ( + "agent_ids", + "backup_directory", + "name", + "expected_name", + "expected_filename", + "expected_agent_ids", + "expected_failed_agent_ids", + "temp_file_unlink_call_count", + ), [ - ([LOCAL_AGENT_ID], "backups", 0), - (["test.remote"], "tmp_backups", 1), - ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ( + [LOCAL_AGENT_ID], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], + 0, + ), + ( + ["test.remote"], + "tmp_backups", + None, + "Custom backup 2025.1.0", + "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + [LOCAL_AGENT_ID], + "backups", + "custom_name", + "custom_name", + "custom_name_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], + 0, + ), + ( + ["test.remote"], + "tmp_backups", + "custom_name", + "custom_name", + "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + "custom_name", + "custom_name", + "custom_name_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + # Test we create a backup when at least one agent is available + [LOCAL_AGENT_ID, "test.unavailable"], + "backups", + "custom_name", + "custom_name", + "custom_name_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + ["test.unavailable"], + 0, + ), ], ) @pytest.mark.parametrize( "params", [ {}, - {"include_database": True, "name": "abc123"}, + {"include_database": True}, {"include_database": False}, {"password": "pass123"}, ], ) -async def test_async_initiate_backup( +async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -221,34 +511,20 @@ async def test_async_initiate_backup( params: dict[str, Any], agent_ids: list[str], backup_directory: str, + name: str | None, + expected_name: str, + expected_filename: str, + expected_agent_ids: list[str], + expected_failed_agent_ids: list[str], temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) - agents = { - f"backup.{local_agent.name}": local_agent, - f"test.{remote_agent.name}": remote_agent, - } - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") include_database = params.get("include_database", True) - name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -261,6 +537,10 @@ async def test_async_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -276,11 +556,12 @@ async def test_async_initiate_backup( patch("pathlib.Path.unlink") as unlink_mock, ): await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": agent_ids} | params + {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params ) result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -295,6 +576,7 @@ async def test_async_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -302,6 +584,7 @@ async def test_async_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -309,6 +592,7 @@ async def test_async_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.COMPLETED, } @@ -325,16 +609,16 @@ async def test_async_initiate_backup( "compressed": True, "date": ANY, "extra": { - "instance_id": hass.data["core.uuid"], + "instance_id": "our_uuid", "with_automatic_settings": False, }, "homeassistant": { "exclude_database": not include_database, "version": "2025.1.0", }, - "name": name, + "name": expected_name, "protected": bool(password), - "slug": ANY, + "slug": backup_id, "type": "partial", "version": 2, } @@ -345,34 +629,25 @@ async def test_async_initiate_backup( result = await ws_client.receive_json() backup_data = result["result"]["backup"] - backup_agent_ids = backup_data.pop("agent_ids") - assert backup_agent_ids == agent_ids assert backup_data == { "addons": [], - "backup_id": ANY, + "agents": { + agent_id: {"protected": bool(password), "size": ANY} + for agent_id in expected_agent_ids + }, + "backup_id": backup_id, "database_included": include_database, "date": ANY, - "failed_agent_ids": [], + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, + "failed_agent_ids": expected_failed_agent_ids, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": name, - "protected": bool(password), - "size": ANY, + "name": expected_name, "with_automatic_settings": False, } - for agent_id in agent_ids: - agent = agents[agent_id] - assert len(agent._backups) == 1 - agent_backup = agent._backups[backup_data["backup_id"]] - assert agent_backup.backup_id == backup_data["backup_id"] - assert agent_backup.date == backup_data["date"] - assert agent_backup.name == backup_data["name"] - assert agent_backup.protected == backup_data["protected"] - assert agent_backup.size == backup_data["size"] - outer_tar = mocked_tarfile.return_value core_tar = outer_tar.create_inner_tar.return_value.__enter__.return_value expected_files = [call(hass.config.path(), arcname="data", recursive=False)] + [ @@ -383,12 +658,12 @@ async def test_async_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_data["backup_id"]}.tar" + assert tar_file_path == f"{backup_directory}/{expected_filename}" @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) -async def test_async_initiate_backup_with_agent_error( +async def test_initiate_backup_with_agent_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -398,7 +673,6 @@ async def test_async_initiate_backup_with_agent_error( ) -> None: """Test agent upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id @@ -411,12 +685,14 @@ async def test_async_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -425,18 +701,18 @@ async def test_async_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -445,8 +721,6 @@ async def test_async_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -457,12 +731,14 @@ async def test_async_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, "failed_agent_ids": [], "folders": [ "media", @@ -471,27 +747,15 @@ async def test_async_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] - remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [backup_1, backup_2, backup_3]}, + ) ws_client = await hass_ws_client(hass) @@ -506,6 +770,10 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -522,17 +790,8 @@ async def test_async_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["success"] is True - delete_backup = AsyncMock() - - with ( - patch("pathlib.Path.open", mock_open(read_data=b"test")), - patch.object( - remote_agent, - "async_upload_backup", - side_effect=exception, - ), - patch.object(remote_agent, "async_delete_backup", delete_backup), - ): + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with patch("pathlib.Path.open", mock_open(read_data=b"test")): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} ) @@ -540,6 +799,7 @@ async def test_async_initiate_backup_with_agent_error( assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, "stage": None, + "reason": None, "state": CreateBackupState.IN_PROGRESS, } result = await ws_client.receive_json() @@ -553,6 +813,7 @@ async def test_async_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -560,6 +821,7 @@ async def test_async_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -567,6 +829,7 @@ async def test_async_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -576,17 +839,16 @@ async def test_async_initiate_backup_with_agent_error( new_expected_backup_data = { "addons": [], - "agent_ids": ["backup.local"], + "agents": {"backup.local": {"protected": False, "size": 123}}, "backup_id": "abc123", "database_included": True, "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, "failed_agent_ids": ["test.remote"], "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": "Custom backup 2025.1.0", - "protected": False, - "size": 123, "with_automatic_settings": False, } @@ -600,6 +862,15 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "create_backup", + "reason": "upload_failed", + "stage": None, + "state": "failed", + }, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -611,7 +882,7 @@ async def test_async_initiate_backup_with_agent_error( ] # one of the two matching backups with the remote agent should have been deleted - assert delete_backup.call_count == 1 + assert mock_agents["test.remote"].async_delete_backup.call_count == 1 @pytest.mark.usefixtures("mock_backup_generation") @@ -635,8 +906,7 @@ async def test_create_backup_success_clears_issue( issues_after_create_backup: set[tuple[str, str]], ) -> None: """Test backup issue is cleared after backup is created.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) # Create a backup issue ir.async_create_issue( @@ -670,7 +940,7 @@ async def test_create_backup_success_clears_issue( assert set(issue_registry.issues) == issues_after_create_backup -async def delayed_boom(*args, **kwargs) -> None: +async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: """Raise an exception after a delay.""" async def delayed_boom() -> None: @@ -682,15 +952,17 @@ async def delayed_boom(*args, **kwargs) -> None: @pytest.mark.parametrize( ( + "automatic_agents", "create_backup_command", "create_backup_side_effect", - "agent_upload_side_effect", + "upload_side_effect", "create_backup_result", "issues_after_create_backup", ), [ # No error ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, None, @@ -698,14 +970,53 @@ async def delayed_boom(*args, **kwargs) -> None: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, None, True, {}, ), + # One agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.unknown"}, + }, + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, + ), # Error raised in async_initiate_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, Exception("Boom!"), None, @@ -713,6 +1024,7 @@ async def delayed_boom(*args, **kwargs) -> None: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, Exception("Boom!"), None, @@ -726,6 +1038,7 @@ async def delayed_boom(*args, **kwargs) -> None: ), # Error raised when awaiting the backup task ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, delayed_boom, None, @@ -733,6 +1046,7 @@ async def delayed_boom(*args, **kwargs) -> None: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, delayed_boom, None, @@ -746,6 +1060,7 @@ async def delayed_boom(*args, **kwargs) -> None: ), # Error raised in async_upload_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, Exception("Boom!"), @@ -753,6 +1068,7 @@ async def delayed_boom(*args, **kwargs) -> None: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, Exception("Boom!"), @@ -760,7 +1076,7 @@ async def delayed_boom(*args, **kwargs) -> None: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), @@ -770,28 +1086,15 @@ async def test_create_backup_failure_raises_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, create_backup: AsyncMock, + automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, - agent_upload_side_effect: Exception | None, + upload_side_effect: Exception | None, create_backup_result: bool, issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], ) -> None: """Test backup issue is cleared after backup is created.""" - remote_agent = BackupAgentTest("remote", backups=[]) - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -800,19 +1103,17 @@ async def test_create_backup_failure_raises_issue( await ws_client.send_json_auto_id( { "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.remote"]}, + "create_backup": {"agent_ids": automatic_agents}, } ) result = await ws_client.receive_json() assert result["success"] is True - with patch.object( - remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect - ): - await ws_client.send_json_auto_id(create_backup_command) - result = await ws_client.receive_json() - assert result["success"] == create_backup_result - await hass.async_block_till_done() + mock_agents["test.remote"].async_upload_backup.side_effect = upload_side_effect + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] == create_backup_result + await hass.async_block_till_done() issue_registry = ir.async_get(hass) assert set(issue_registry.issues) == set(issues_after_create_backup) @@ -826,7 +1127,7 @@ async def test_create_backup_failure_raises_issue( @pytest.mark.parametrize( "exception", [BackupReaderWriterError("Boom!"), BaseException("Boom!")] ) -async def test_async_initiate_backup_non_agent_upload_error( +async def test_initiate_backup_non_agent_upload_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -835,29 +1136,8 @@ async def test_async_initiate_backup_non_agent_upload_error( exception: Exception, ) -> None: """Test an unknown or writer upload error during backup generation.""" - hass_storage[DOMAIN] = { - "data": {}, - "key": DOMAIN, - "version": 1, - } agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -872,6 +1152,10 @@ async def test_async_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -882,20 +1166,15 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["success"] is True - with ( - patch("pathlib.Path.open", mock_open(read_data=b"test")), - patch.object( - remote_agent, - "async_upload_backup", - side_effect=exception, - ), - ): + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with patch("pathlib.Path.open", mock_open(read_data=b"test")): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} ) result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -910,6 +1189,7 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -917,6 +1197,7 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -924,6 +1205,7 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -931,14 +1213,14 @@ async def test_async_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == {"manager_state": BackupManagerState.IDLE} - assert not hass_storage[DOMAIN]["data"] + assert DOMAIN not in hass_storage @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( "exception", [BackupReaderWriterError("Boom!"), Exception("Boom!")] ) -async def test_async_initiate_backup_with_task_error( +async def test_initiate_backup_with_task_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -951,23 +1233,8 @@ async def test_async_initiate_backup_with_task_error( backup_task.set_exception(exception) create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -982,6 +1249,10 @@ async def test_async_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1000,6 +1271,7 @@ async def test_async_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1007,6 +1279,7 @@ async def test_async_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1040,7 +1313,7 @@ async def test_async_initiate_backup_with_task_error( (1, None, 1, None, 1, None, 1, OSError("Boom!")), ], ) -async def test_initiate_backup_file_error( +async def test_initiate_backup_file_error_upload_to_agents( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -1054,24 +1327,10 @@ async def test_initiate_backup_file_error( unlink_call_count: int, unlink_exception: Exception | None, ) -> None: - """Test file error during generate backup.""" + """Test file error during generate backup, while uploading to agents.""" agent_ids = ["test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1086,6 +1345,10 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1112,6 +1375,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1126,6 +1390,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -1133,6 +1398,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -1140,6 +1406,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1153,59 +1420,145 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count -async def test_loading_platforms( +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "mkdir_call_count", + "mkdir_exception", + "atomic_contents_add_call_count", + "atomic_contents_add_exception", + "stat_call_count", + "stat_exception", + "error_message", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, "Failed to create dir"), + (1, None, 1, OSError("Boom!"), 0, None, "Boom!"), + (1, None, 1, None, 1, OSError("Boom!"), "Error getting size"), + ], +) +async def test_initiate_backup_file_error_create_backup( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, caplog: pytest.LogCaptureFixture, + mkdir_call_count: int, + mkdir_exception: Exception | None, + atomic_contents_add_call_count: int, + atomic_contents_add_exception: Exception | None, + stat_call_count: int, + stat_exception: Exception | None, + error_message: str, ) -> None: - """Test loading backup platforms.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + """Test file error during generate backup, while creating backup.""" + agent_ids = ["test.remote"] - assert not manager.platforms + await setup_backup_integration(hass, remote_agents=["test.remote"]) - get_agents_mock = AsyncMock(return_value=[]) + ws_client = await hass_ws_client(hass) - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=AsyncMock(), - async_get_backup_agents=get_agents_mock, - ), - ) - await manager.load_platforms() - await hass.async_block_till_done() + path_glob.return_value = [] - assert len(manager.platforms) == 1 - assert "Loaded 1 platforms" in caplog.text + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() - get_agents_mock.assert_called_once_with(hass) + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch( + "homeassistant.components.backup.manager.atomic_contents_add", + side_effect=atomic_contents_add_exception, + ) as atomic_contents_add_mock, + patch("pathlib.Path.mkdir", side_effect=mkdir_exception) as mkdir_mock, + patch("pathlib.Path.stat", side_effect=stat_exception) as stat_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert atomic_contents_add_mock.call_count == atomic_contents_add_call_count + assert mkdir_mock.call_count == mkdir_call_count + assert stat_mock.call_count == stat_call_count + + assert error_message in caplog.text -class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): - """Local backup agent.""" - - def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return Path("test.tar") +def _mock_local_backup_agent(name: str) -> Mock: + local_agent = mock_backup_agent(name) + # This makes the local_agent pass isinstance checks for LocalBackupAgent + local_agent.mock_add_spec(LocalBackupAgent) + return local_agent @pytest.mark.parametrize( - ("agent_class", "num_local_agents"), - [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)], + ("agent_creator", "num_local_agents"), + [(_mock_local_backup_agent, 2), (mock_backup_agent, 1)], ) async def test_loading_platform_with_listener( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - agent_class: type[BackupAgentTest], + agent_creator: Callable[[str], Mock], num_local_agents: int, ) -> None: """Test loading a backup agent platform which can be listened to.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await setup_backup_integration(hass) manager = hass.data[DATA_MANAGER] - get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) + get_agents_mock = AsyncMock(return_value=[agent_creator("remote1")]) register_listener_mock = Mock() await setup_backup_platform( @@ -1221,8 +1574,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote1"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote1", "name": "remote1"}, ] assert len(manager.local_backup_agents) == num_local_agents @@ -1230,7 +1583,7 @@ async def test_loading_platform_with_listener( register_listener_mock.assert_called_once_with(hass, listener=ANY) get_agents_mock.reset_mock() - get_agents_mock.return_value = [agent_class("remote2", backups=[])] + get_agents_mock.return_value = [agent_creator("remote2")] listener = register_listener_mock.call_args[1]["listener"] listener() @@ -1238,8 +1591,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote2"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote2", "name": "remote2"}, ] assert len(manager.local_backup_agents) == num_local_agents @@ -1262,8 +1615,7 @@ async def test_not_loading_bad_platforms( domain="test", platform=platform_mock, ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) assert platform_mock.mock_calls == [] @@ -1274,7 +1626,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1284,8 +1636,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) with pytest.raises(BackupManagerError) as err: await hass.services.async_call( @@ -1297,35 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: assert str(err.value) == "Error during pre-backup: Test exception" +@pytest.mark.parametrize( + ("unhandled_error", "expected_exception", "expected_msg"), + [ + (None, BackupManagerError, "Error during post-backup: Test exception"), + ( + HomeAssistantError("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ( + Exception("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ], +) @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post(hass: HomeAssistant) -> None: +async def test_exception_platform_post( + hass: HomeAssistant, + unhandled_error: Exception | None, + expected_exception: type[Exception], + expected_msg: str, +) -> None: """Test exception in post step.""" - async def _mock_step(hass: HomeAssistant) -> None: - raise HomeAssistantError("Test exception") - - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=_mock_step, + # We let the pre_backup fail to test that unhandled errors are not discarded + # when post backup fails + async_pre_backup=AsyncMock(side_effect=unhandled_error), + async_post_backup=AsyncMock( + side_effect=HomeAssistantError("Test exception") + ), async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) - with pytest.raises(BackupManagerError) as err: + with pytest.raises(expected_exception, match=re.escape(expected_msg)): await hass.services.async_call( DOMAIN, "create", blocking=True, ) - assert str(err.value) == "Error during post-backup: Test exception" - @pytest.mark.parametrize( ( @@ -1342,8 +1718,8 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["abc123.tar"], - {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + ["Test_1970-01-01_00.00_00000000.tar"], + {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")}, b"test", 0, ), @@ -1351,7 +1727,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["abc123.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {}, None, 0, @@ -1361,7 +1737,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: 2, 0, [], - {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")}, b"test", 1, ), @@ -1379,17 +1755,7 @@ async def test_receive_backup( temp_file_unlink_call_count: int, ) -> None: """Test receive backup and upload to the local and a remote agent.""" - remote_agent = BackupAgentTest("remote", backups=[]) - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() upload_data = "test" @@ -1419,8 +1785,13 @@ async def test_receive_backup( assert move_mock.call_count == move_call_count for index, name in enumerate(move_path_names): assert move_mock.call_args_list[index].args[1].name == name - assert remote_agent._backups == remote_agent_backups - assert remote_agent._backup_data == remote_agent_backup_data + remote_agent = mock_agents["test.remote"] + for backup_id, (backup, expected_backup_data) in remote_agent_backups.items(): + assert await remote_agent.async_get_backup(backup_id) == backup + backup_data = bytearray() + async for chunk in await remote_agent.async_download_backup(backup_id): + backup_data += chunk + assert backup_data == expected_backup_data assert unlink_mock.call_count == temp_file_unlink_call_count @@ -1429,10 +1800,13 @@ async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + create_backup: AsyncMock, ) -> None: """Test receive backup with a busy manager.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + new_backup = NewBackup(backup_job_id="time-123") + backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() + create_backup.return_value = (new_backup, backup_task) + await setup_backup_integration(hass) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -1445,24 +1819,19 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["success"] is True - new_backup = NewBackup(backup_job_id="time-123") - backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() - with patch( - "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", - return_value=(new_backup, backup_task), - ) as create_backup: - await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": ["backup.local"]} - ) - result = await ws_client.receive_json() - assert result["event"] == { - "manager_state": "create_backup", - "stage": None, - "state": "in_progress", - } - result = await ws_client.receive_json() - assert result["success"] is True - assert result["result"] == {"backup_job_id": "time-123"} + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == {"backup_job_id": "time-123"} assert create_backup.call_count == 1 @@ -1488,42 +1857,779 @@ async def test_receive_backup_busy_manager( await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [BackupAgentError("Boom!"), Exception("Boom!")]) +async def test_receive_backup_agent_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test upload error during backup receive.""" + backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id + backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id + backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id + backups_info: list[dict[str, Any]] = [ + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agents": {"test.remote": {"protected": False, "size": 0}}, + "backup_id": "backup1", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": True, + }, + { + "addons": [], + "agents": {"test.remote": {"protected": False, "size": 1}}, + "backup_id": "backup2", + "database_included": False, + "date": "1980-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test 2", + "with_automatic_settings": None, + }, + { + "addons": [ + { + "name": "Test", + "slug": "test", + "version": "1.0.0", + }, + ], + "agents": {"test.remote": {"protected": False, "size": 0}}, + "backup_id": "backup3", + "database_included": True, + "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": { + "instance_id": "our_uuid", + "with_automatic_settings": True, + }, + "failed_agent_ids": [], + "folders": [ + "media", + "share", + ], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": True, + }, + ] + + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [backup_1, backup_2, backup_3]}, + ) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id( + {"type": "backup/config/update", "retention": {"copies": 1, "days": None}} + ) + result = await ws_client.receive_json() + assert result["success"] + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with ( + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": None, + "state": ReceiveBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": backups_info, + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "receive_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"]["backups"] == [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ] + + assert resp.status == 201 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 1 + assert mock_agents["test.remote"].async_delete_backup.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize("exception", [asyncio.CancelledError("Boom!")]) +async def test_receive_backup_non_agent_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + hass_storage: dict[str, Any], + exception: Exception, +) -> None: + """Test non agent upload error during backup receive.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with ( + patch("pathlib.Path.open", open_mock), + patch("shutil.move") as move_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch("pathlib.Path.unlink") as unlink_mock, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert DOMAIN not in hass_storage + assert resp.status == 500 + assert open_mock.call_count == 1 + assert move_mock.call_count == 0 + assert unlink_mock.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_id", "password", "restore_database", "restore_homeassistant", "dir"), + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + ), [ - (LOCAL_AGENT_ID, None, True, False, "backups"), - (LOCAL_AGENT_ID, "abc123", False, True, "backups"), - ("test.remote", None, True, True, "tmp_backups"), + (1, OSError("Boom!"), 0, None, 0, None), + (1, None, 1, OSError("Boom!"), 1, None), + (1, None, 1, None, 1, OSError("Boom!")), ], ) -async def test_async_trigger_restore( +async def test_receive_backup_file_write_error( hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: Exception | None, + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: Exception | None, +) -> None: + """Test file write error during backup receive.""" + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + "exception", + [ + OSError("Boom!"), + tarfile.TarError("Boom!"), + json.JSONDecodeError("Boom!", "test", 1), + KeyError("Boom!"), + ], +) +async def test_receive_backup_read_tar_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + exception: Exception, +) -> None: + """Test read tar error during backup receive.""" + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "homeassistant.components.backup.manager.read_backup", + side_effect=exception, + ) as read_backup, + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", + "stage": None, + "state": ReceiveBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == 500 + assert read_backup.call_count == 1 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "read_call_count", + "read_exception", + "close_call_count", + "close_exception", + "unlink_call_count", + "unlink_exception", + "final_state", + "final_state_reason", + "response_status", + ), + [ + ( + 2, + [DEFAULT, OSError("Boom!")], + 0, + None, + 1, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + None, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + 2, + [DEFAULT, DEFAULT], + 1, + None, + ReceiveBackupState.COMPLETED, + None, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, OSError("Boom!")], + 1, + None, + ReceiveBackupState.COMPLETED, + None, + 201, + ), + ( + 2, + [DEFAULT, DEFAULT], + 1, + None, + 2, + [DEFAULT, DEFAULT], + 1, + OSError("Boom!"), + ReceiveBackupState.FAILED, + "unknown_error", + 500, + ), + ], +) +async def test_receive_backup_file_read_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + path_glob: MagicMock, + open_call_count: int, + open_exception: list[Exception | None], + read_call_count: int, + read_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + unlink_call_count: int, + unlink_exception: Exception | None, + final_state: ReceiveBackupState, + final_state_reason: str | None, + response_status: int, +) -> None: + """Test file read error during backup receive.""" + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + client = await hass_client() + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + + open_mock.side_effect = open_exception + open_mock.return_value.read.side_effect = read_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch("pathlib.Path.unlink", side_effect=unlink_exception) as unlink_mock, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ), + ): + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO(upload_data)}, + ) + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": None, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.RECEIVE_FILE, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, + "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, + "state": ReceiveBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": final_state_reason, + "stage": None, + "state": final_state, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert resp.status == response_status + assert open_mock.call_count == open_call_count + assert open_mock.return_value.read.call_count == read_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert unlink_mock.call_count == unlink_call_count + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), + [ + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), + ], +) +async def test_restore_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, agent_id: str, - password: str | None, + backup_id: str, + password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, ) -> None: - """Test trigger restore.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - hass.data[DATA_MANAGER] = manager - - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await setup_backup_platform( + """Test restore backup.""" + password = password_param.get("password") + await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] - ), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) - await manager.load_platforms() - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True with ( patch("pathlib.Path.exists", return_value=True), @@ -1533,135 +2639,833 @@ async def test_async_trigger_restore( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(BackupAgentTest, "async_download_backup") as download_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + side_effect=mock_read_backup, + ), ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - await manager.async_restore_backup( - TEST_BACKUP_ABC123.backup_id, - agent_id=agent_id, - password=password, - restore_addons=None, - restore_database=restore_database, - restore_folders=None, - restore_homeassistant=restore_homeassistant, - ) - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" - expected_restore_file = json.dumps( + await ws_client.send_json_auto_id( { - "path": backup_path, - "password": password, - "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "type": "backup/restore", + "backup_id": backup_id, + "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } + | password_param ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) - assert mocked_write_text.call_args[0][0] == expected_restore_file - assert mocked_service_call.called + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } -async def test_async_trigger_restore_wrong_password(hass: HomeAssistant) -> None: - """Test trigger restore.""" - password = "hunter2" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) - hass.data[DATA_MANAGER] = manager + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.CORE_RESTART, + } - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])] - ), - spec_set=BackupAgentPlatformProtocol, - ), + # Note: The core restart is not tested here, in reality the following events + # are not sent because the core restart closes the WS connection. + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" + expected_restore_file = json.dumps( + { + "path": full_backup_path, + "password": password, + "remove_after_restore": agent_id != LOCAL_AGENT_ID, + "restore_database": restore_database, + "restore_homeassistant": restore_homeassistant, + } ) - await manager.load_platforms() + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) + assert mocked_write_text.call_args[0][0] == expected_restore_file + assert mocked_service_call.called - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("agent_id", "dir"), [(LOCAL_AGENT_ID, "backups"), ("test.remote", "tmp_backups")] +) +async def test_restore_backup_wrong_password( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + agent_id: str, + dir: str, +) -> None: + """Test restore backup wrong password.""" + password = "hunter2" + await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, + ) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + patch( + "homeassistant.components.backup.backup.read_backup", + side_effect=mock_read_backup, + ), + ): + validate_password_mock.return_value = False + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": agent_id, + "password": password, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "password_incorrect", + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "password_incorrect" + + backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + validate_password_mock.assert_called_once_with(Path(backup_path), password) + mocked_write_text.assert_not_called() + mocked_service_call.assert_not_called() + + +@pytest.mark.usefixtures("path_glob") +@pytest.mark.parametrize( + ("parameters", "expected_error", "expected_reason"), + [ + ( + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", + "backup_manager_error", + ), + ( + {"restore_addons": ["blah"]}, + "Addons and folders are not supported in core restore", + "backup_reader_writer_error", + ), + ( + {"restore_folders": [Folder.ADDONS]}, + "Addons and folders are not supported in core restore", + "backup_reader_writer_error", + ), + ( + {"restore_database": False, "restore_homeassistant": False}, + "Home Assistant or database must be included in restore", + "backup_reader_writer_error", + ), + ], +) +async def test_restore_backup_wrong_parameters( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + parameters: dict[str, Any], + expected_error: str, + expected_reason: str, +) -> None: + """Test restore backup wrong parameters.""" + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True with ( patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( - "homeassistant.components.backup.manager.validate_password" - ) as validate_password_mock, + "homeassistant.components.backup.backup.read_backup", + side_effect=mock_read_backup, + ), ): - validate_password_mock.return_value = False - with pytest.raises( - HomeAssistantError, match="The password provided is incorrect." - ): - await manager.async_restore_backup( - TEST_BACKUP_ABC123.backup_id, - agent_id=LOCAL_AGENT_ID, - password=password, - restore_addons=None, - restore_database=True, - restore_folders=None, - restore_homeassistant=True, - ) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + | parameters + ) - backup_path = f"{hass.config.path()}/backups/abc123.tar" - validate_password_mock.assert_called_once_with(Path(backup_path), password) - mocked_write_text.assert_not_called() - mocked_service_call.assert_not_called() + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, + "stage": None, + "state": RestoreBackupState.FAILED, + } -@pytest.mark.parametrize( - ("parameters", "expected_error"), - [ - ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - "Backup def456 not found", - ), - ( - {"restore_addons": ["blah"]}, - "Addons and folders are not supported in core restore", - ), - ( - {"restore_folders": [Folder.ADDONS]}, - "Addons and folders are not supported in core restore", - ), - ( - {"restore_database": False, "restore_homeassistant": False}, - "Home Assistant or database must be included in restore", - ), - ], -) -async def test_async_trigger_restore_wrong_parameters( - hass: HomeAssistant, parameters: dict[str, Any], expected_error: str -) -> None: - """Test trigger restore.""" - manager = BackupManager(hass, CoreBackupReaderWriter(hass)) + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} - await setup_backup_platform(hass, domain=DOMAIN, platform=local_backup_platform) - await manager.load_platforms() - - local_agent = manager.backup_agents[LOCAL_AGENT_ID] - local_agent._backups = {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123} - local_agent._loaded_backups = True - - default_parameters = { - "agent_id": LOCAL_AGENT_ID, - "backup_id": TEST_BACKUP_ABC123.backup_id, - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, - } - - with ( - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.write_text") as mocked_write_text, - patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, - pytest.raises(HomeAssistantError, match=expected_error), - ): - await manager.async_restore_backup(**(default_parameters | parameters)) + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == expected_error mocked_write_text.assert_not_called() mocked_service_call.assert_not_called() + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_restore_backup_when_busy( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup with busy manager.""" + await setup_backup_integration(hass) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is True + + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Backup manager busy: create_backup" + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("exception", "error_code", "error_message", "expected_reason"), + [ + ( + BackupAgentError("Boom!"), + "home_assistant_error", + "Boom!", + "backup_agent_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", + ), + ], +) +async def test_restore_backup_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + exception: Exception, + error_code: str, + error_message: str, + expected_reason: str, +) -> None: + """Test restore backup with agent error.""" + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, + ) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + mock_agents["test.remote"].async_download_backup.side_effect = exception + with ( + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + ): + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == error_code + assert result["error"]["message"] == error_message + + assert mock_agents["test.remote"].async_download_backup.call_count == 1 + assert mocked_write_text.call_count == 0 + assert mocked_service_call.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + "write_text_call_count", + "write_text_exception", + "validate_password_call_count", + ), + [ + ( + 1, + OSError("Boom!"), + 0, + None, + 0, + None, + 0, + None, + 0, + ), + ( + 1, + None, + 1, + OSError("Boom!"), + 1, + None, + 0, + None, + 0, + ), + ( + 1, + None, + 1, + None, + 1, + OSError("Boom!"), + 0, + None, + 0, + ), + ( + 1, + None, + 1, + None, + 1, + None, + 1, + OSError("Boom!"), + 1, + ), + ], +) +async def test_restore_backup_file_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + open_call_count: int, + open_exception: list[Exception | None], + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + write_text_call_count: int, + write_text_exception: Exception | None, + validate_password_call_count: int, +) -> None: + """Test restore backup with file error.""" + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, + ) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + open_mock = mock_open() + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "pathlib.Path.write_text", side_effect=write_text_exception + ) as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + ): + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "unknown_error", + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "unknown_error" + assert result["error"]["message"] == "Unknown error" + + assert mock_agents["test.remote"].async_download_backup.call_count == 1 + assert validate_password_mock.call_count == validate_password_call_count + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert mocked_write_text.call_count == write_text_call_count + assert mocked_service_call.call_count == 0 + + +@pytest.mark.parametrize( + ("commands", "agent_ids", "password", "protected_backup", "inner_tar_key"), + [ + ( + [], + ["backup.local", "test.remote"], + None, + {"backup.local": False, "test.remote": False}, + None, + ), + ( + [], + ["backup.local", "test.remote"], + "hunter2", + {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + ["backup.local", "test.remote"], + "hunter2", + {"backup.local": False, "test.remote": False}, + None, # None of the agents are protected + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["backup.local", "test.remote"], + "hunter2", + {"backup.local": False, "test.remote": True}, + None, # Local agent is not protected + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": False}, + }, + } + ], + ["backup.local", "test.remote"], + "hunter2", + {"backup.local": True, "test.remote": False}, + password_to_key("hunter2"), # Local agent is protected + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": True}, + }, + } + ], + ["backup.local", "test.remote"], + "hunter2", + {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["backup.local", "test.remote"], + None, + {"backup.local": False, "test.remote": False}, + None, # No password supplied + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": False}, + password_to_key("hunter2"), # Temporary backup protected when password set + ), + ], +) +@pytest.mark.usefixtures("mock_backup_generation") +async def test_initiate_backup_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + mocked_tarfile: Mock, + path_glob: MagicMock, + commands: dict[str, Any], + agent_ids: list[str], + password: str | None, + protected_backup: dict[str, bool], + inner_tar_key: bytes | None, +) -> None: + """Test generate backup where encryption is selectively set on agents.""" + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + for command in commands: + await ws_client.send_json_auto_id(command) + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + ): + await ws_client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "password": password, + "name": "test", + } + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + mocked_tarfile.return_value.create_inner_tar.assert_called_once_with( + ANY, gzip=True, key=inner_tar_key + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} + ) + result = await ws_client.receive_json() + + backup_data = result["result"]["backup"] + + assert backup_data == { + "addons": [], + "agents": { + agent_id: {"protected": protected_backup[agent_id], "size": ANY} + for agent_id in agent_ids + }, + "backup_id": backup_id, + "database_included": True, + "date": ANY, + "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, + "failed_agent_ids": [], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": "test", + "with_automatic_settings": False, + } + + +@pytest.mark.parametrize( + ("restore_result", "last_non_idle_event"), + [ + ( + {"error": None, "error_type": None, "success": True}, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + {"error": "Boom!", "error_type": "ValueError", "success": False}, + { + "manager_state": "restore_backup", + "reason": "Boom!", + "stage": None, + "state": "failed", + }, + ), + ], +) +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + restore_result: dict[str, Any], + last_non_idle_event: dict[str, Any], +) -> None: + """Test restore backup progress after restart.""" + + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": last_non_idle_event, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + +async def test_restore_progress_after_restart_fail_to_remove( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test restore backup progress after restart when failing to remove result file.""" + + with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")): + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + assert ( + "Unexpected error deleting backup restore result file: Boom!" + in caplog.text + ) diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py new file mode 100644 index 00000000000..0d29bb2006a --- /dev/null +++ b/tests/components/backup/test_store.py @@ -0,0 +1,231 @@ +"""Tests for the Backup integration.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def mock_delay_save() -> Generator[None]: + """Mock the delay save constant.""" + with patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0): + yield + + +@pytest.mark.parametrize( + "store_data", + [ + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": 0, + "days": 0, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "time": None, + }, + "something_from_the_future": "value", + }, + }, + "key": DOMAIN, + "version": 2, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, + ], +) +async def test_store_migration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + store_data: dict[str, Any], +) -> None: + """Test migrating the backup store.""" + hass_storage[DOMAIN] = store_data + await setup_backup_integration(hass) + await hass.async_block_till_done() + + # Check migrated data + assert hass_storage[DOMAIN] == snapshot + + # Update settings, then check saved data + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + } + ) + result = await client.receive_json() + assert result["success"] + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 60cfc77b1aa..97e94eafb73 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -2,13 +2,26 @@ from __future__ import annotations +import asyncio +from collections.abc import AsyncIterator +import dataclasses import tarfile from unittest.mock import Mock, patch import pytest +import securetar -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder -from homeassistant.components.backup.util import read_backup, validate_password +from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.util import ( + DecryptedBackupStreamer, + EncryptedBackupStreamer, + read_backup, + suggested_filename, + validate_password, +) +from homeassistant.core import HomeAssistant + +from tests.common import get_fixture_path @pytest.mark.parametrize( @@ -76,6 +89,28 @@ from homeassistant.components.backup.util import read_backup, validate_password size=1234, ), ), + # Check the backup_request_date is used as date if present + ( + b'{"compressed":true,"date":"2024-12-01T00:00:00.000000-00:00","homeassistant":' + b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"test",' + b'"extra":{"supervisor.backup_request_date":"2025-12-01T00:00:00.000000-00:00"},' + b'"protected":true,"slug":"455645fe","type":"partial","version":2}', + AgentBackup( + addons=[], + backup_id="455645fe", + date="2025-12-01T00:00:00.000000-00:00", + database_included=False, + extra_metadata={ + "supervisor.backup_request_date": "2025-12-01T00:00:00.000000-00:00" + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=1234, + ), + ), ], ) def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None: @@ -130,3 +165,411 @@ def test_validate_password_no_homeassistant() -> None: KeyError ) assert validate_password(mock_path, "hunter2") is False + + +async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert decryptor.backup() == dataclasses.replace( + backup, protected=False, size=backup.size + len(expected_padding) + ) + decrypted_stream = await decryptor.open_stream() + decrypted_output = b"" + async for chunk in decrypted_stream: + decrypted_output += chunk + await decryptor.wait() + + # Expect the output to match the stored decrypted backup file, with additional + # padding. + decrypted_backup_data = decrypted_backup_path.read_bytes() + assert decrypted_output == decrypted_backup_data + expected_padding + + +async def test_decrypted_backup_streamer_interrupt_stuck_reader( + hass: HomeAssistant, +) -> None: + """Test the decrypted backup streamer.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + stuck = asyncio.Event() + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + await stuck.wait() + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_decrypted_backup_streamer_interrupt_stuck_writer( + hass: HomeAssistant, +) -> None: + """Test the decrypted backup streamer.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer with wrong password.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "wrong_password") + decrypted_stream = await decryptor.open_stream() + async for _ in decrypted_stream: + pass + + await decryptor.wait() + assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError) + + +async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + with patch("os.urandom") as mock_randbytes: + mock_randbytes.side_effect = ( + bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"), + bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"), + bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"), + bytes.fromhex("00000000000000000000000000000000"), + ) + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert encryptor.backup() == dataclasses.replace( + backup, protected=True, size=backup.size + len(expected_padding) + ) + + encrypted_stream = await encryptor.open_stream() + encrypted_output = b"" + async for chunk in encrypted_stream: + encrypted_output += chunk + await encryptor.wait() + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + assert encrypted_output == encrypted_backup_data + expected_padding + + +async def test_encrypted_backup_streamer_interrupt_stuck_reader( + hass: HomeAssistant, +) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + stuck = asyncio.Event() + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + await stuck.wait() + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_encrypted_backup_streamer_interrupt_stuck_writer( + hass: HomeAssistant, +) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + await decryptor.open_stream() + await decryptor.wait() + + +async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + encryptor1 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + encryptor2 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + async def read_stream(stream: AsyncIterator[bytes]) -> bytes: + output = b"" + async for chunk in stream: + output += chunk + return output + + # When reading twice from the same streamer, the same nonce is used. + encrypted_output1 = await read_stream(await encryptor1.open_stream()) + encrypted_output2 = await read_stream(await encryptor1.open_stream()) + assert encrypted_output1 == encrypted_output2 + + encrypted_output3 = await read_stream(await encryptor2.open_stream()) + encrypted_output4 = await read_stream(await encryptor2.open_stream()) + assert encrypted_output3 == encrypted_output4 + + # Wait for workers to terminate + await encryptor1.wait() + await encryptor2.wait() + + # Output from the two streames should differ but have the same length. + assert encrypted_output1 != encrypted_output3 + assert len(encrypted_output1) == len(encrypted_output3) + + # Expect the output length to match the stored encrypted backup file, with + # additional padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + # 4 x 10240 byte of padding + assert len(encrypted_output1) == len(encrypted_backup_data) + 40960 + assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data + + +async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + with patch( + "homeassistant.components.backup.util.tarfile.open", + side_effect=tarfile.TarError, + ): + encrypted_stream = await encryptor.open_stream() + async for _ in encrypted_stream: + pass + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + await encryptor.wait() + assert isinstance(encryptor._workers[0].error, tarfile.TarError) + + +@pytest.mark.parametrize( + ("name", "resulting_filename"), + [ + ("test", "test_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_2025-01-30_13.42_12345678.tar"), + ], +) +def test_suggested_filename(name: str, resulting_filename: str) -> None: + """Test suggesting a filename.""" + backup = AgentBackup( + addons=[], + backup_id="1234", + date="2025-01-30 13:42:12.345678-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name=name, + protected=False, + size=1234, + ) + assert suggested_filename(backup) == resulting_filename diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 307a1d79e0c..404ba52de4b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -11,13 +11,15 @@ from syrupy import SnapshotAssertion from homeassistant.components.backup import ( AgentBackup, BackupAgentError, - BackupAgentPlatformProtocol, + BackupNotFound, BackupReaderWriterError, Folder, + store, ) from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.components.backup.manager import ( + AgentBackupStatus, CreateBackupEvent, CreateBackupState, ManagerBackup, @@ -25,13 +27,15 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, - BackupAgentTest, + mock_backup_agent, setup_backup_integration, setup_backup_platform, ) @@ -43,18 +47,20 @@ BACKUP_CALL = call( agent_ids=["test.test-agent"], backup_name="test-name", extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, - include_addons=["test-addon"], + include_addons=[], include_all_addons=False, include_database=True, - include_folders=["media"], + include_folders=None, include_homeassistant=True, password="test-password", on_progress=ANY, ) DEFAULT_STORAGE_DATA: dict[str, Any] = { - "backups": {}, + "backups": [], "config": { + "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -70,11 +76,10 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "copies": None, "days": None, }, - "schedule": { - "state": "never", - }, + "schedule": {"days": [], "recurrence": "never", "state": "never", "time": None}, }, } +DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] @pytest.fixture @@ -96,15 +101,6 @@ def mock_delay_save() -> Generator[None]: yield -@pytest.fixture(name="delete_backup") -def mock_delete_backup() -> Generator[AsyncMock]: - """Mock manager delete backup.""" - with patch( - "homeassistant.components.backup.BackupManager.async_delete_backup" - ) as mock_delete_backup: - yield mock_delete_backup - - @pytest.fixture(name="get_backups") def mock_get_backups() -> Generator[AsyncMock]: """Mock manager get backups.""" @@ -118,9 +114,9 @@ def mock_get_backups() -> Generator[AsyncMock]: ("remote_agents", "remote_backups"), [ ([], {}), - (["remote"], {}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ], ) async def test_info( @@ -146,7 +142,8 @@ async def test_info( @pytest.mark.parametrize( - "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] + "side_effect", + [Exception("Oops"), HomeAssistantError("Boom!"), BackupAgentUnreachableError], ) async def test_info_with_errors( hass: HomeAssistant, @@ -155,28 +152,30 @@ async def test_info_with_errors( snapshot: SnapshotAssertion, ) -> None: """Test getting backup info with one unavailable agent.""" - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agents["test.remote"].async_list_backups.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect): - await client.send_json_auto_id({"type": "backup/info"}) - assert await client.receive_json() == snapshot + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( ("remote_agents", "backups"), [ ([], {}), - (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ( - ["remote"], + ["test.remote"], { LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], "test.remote": [TEST_BACKUP_ABC123], @@ -207,7 +206,8 @@ async def test_details( @pytest.mark.parametrize( - "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] + "side_effect", + [Exception("Oops"), HomeAssistantError("Boom!"), BackupAgentUnreachableError], ) async def test_details_with_errors( hass: HomeAssistant, @@ -216,18 +216,18 @@ async def test_details_with_errors( snapshot: SnapshotAssertion, ) -> None: """Test getting backup info with one unavailable agent.""" - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agents["test.remote"].async_get_backup.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect), - ): + with patch("pathlib.Path.exists", return_value=True): await client.send_json_auto_id( {"type": "backup/details", "backup_id": "abc123"} ) @@ -238,11 +238,11 @@ async def test_details_with_errors( ("remote_agents", "backups"), [ ([], {}), - (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ( - ["remote"], + ["test.remote"], { LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], "test.remote": [TEST_BACKUP_ABC123], @@ -305,19 +305,25 @@ async def test_delete_with_errors( hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={ + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + remote_agents=["test.remote"], ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agents["test.remote"].async_delete_backup.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect): - await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) - assert await client.receive_json() == snapshot + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot await client.send_json_auto_id({"type": "backup/info"}) assert await client.receive_json() == snapshot @@ -329,22 +335,22 @@ async def test_agent_delete_backup( snapshot: SnapshotAssertion, ) -> None: """Test deleting a backup file with a mock agent.""" - await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + mock_agents = await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock: - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": "abc123", - } - ) - assert await client.receive_json() == snapshot + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "abc123", + } + ) + assert await client.receive_json() == snapshot - assert delete_mock.call_args == call("abc123") + assert mock_agents["test.remote"].async_delete_backup.call_args == call("abc123") @pytest.mark.parametrize( @@ -586,20 +592,14 @@ async def test_generate_with_default_settings_calls_create( last_completed_automatic_backup: str, ) -> None: """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = create_backup_settings["password"] is not None client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") - remote_agent = BackupAgentTest("remote", backups=[]) - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + mock_agents = await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] ) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() await client.send_json_auto_id( {"type": "backup/config/update", "create_backup": create_backup_settings} @@ -624,15 +624,13 @@ async def test_generate_with_default_settings_calls_create( is None ) - with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect): - await client.send_json_auto_id( - {"type": "backup/generate_with_automatic_settings"} - ) - result = await client.receive_json() - assert result["success"] - assert result["result"] == {"backup_job_id": "abc123"} + mock_agents["test.remote"].async_upload_backup.side_effect = side_effect + await client.send_json_auto_id({"type": "backup/generate_with_automatic_settings"}) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} - await hass.async_block_till_done() + await hass.async_block_till_done() create_backup.assert_called_once_with(**expected_call_params) @@ -689,8 +687,8 @@ async def test_restore_local_agent( @pytest.mark.parametrize( ("remote_agents", "backups"), [ - (["remote"], {}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), ], ) async def test_restore_remote_agent( @@ -701,6 +699,7 @@ async def test_restore_remote_agent( snapshot: SnapshotAssertion, ) -> None: """Test calling the restore command.""" + await setup_backup_integration( hass, with_hassio=False, backups=backups, remote_agents=remote_agents ) @@ -892,8 +891,9 @@ async def test_agents_info( snapshot: SnapshotAssertion, ) -> None: """Test getting backup agents info.""" - await setup_backup_integration(hass, with_hassio=False) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -902,235 +902,546 @@ async def test_agents_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "storage_data", [ - None, + {}, { - "backups": {}, - "config": { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": 3, "days": 7}, + "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": { + "days": DAILY, + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": 3, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": 7}, + "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", + "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, + "automatic_backups_configured": False, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["hassio.local", "hassio.share", "test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["backup.local", "test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + ], +) +@pytest.mark.parametrize( + ("with_hassio"), + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +@pytest.mark.usefixtures("supervisor_client") +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) +async def test_config_load_config_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + hass_storage: dict[str, Any], + with_hassio: bool, + storage_data: dict[str, Any] | None, +) -> None: + """Test loading stored backup config and reading it via config/info.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + hass_storage.update(storage_data) + + await setup_backup_integration(hass, with_hassio=with_hassio) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("get_backups") +@pytest.mark.parametrize( + "commands", + [ + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "daily", "time": "06:00"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "never"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], - "include_all_addons": True, - "include_database": True, "include_folders": ["media"], "name": "test-name", "password": "test-password", }, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": 7}, - "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", - "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "daily"}, - }, - }, - { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, - }, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "never"}, - }, - }, - { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, - }, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 7}, - "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", - "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "never"}, - }, - }, - { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, }, - "retention": {"copies": None, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "mon"}, - }, - }, - { - "backups": {}, - "config": { - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": None, - "include_all_addons": False, - "include_database": False, - "include_folders": None, - "name": None, - "password": None, + } + ], + [ + # Test we can update AgentConfig + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, }, - "retention": {"copies": None, "days": None}, - "last_attempted_automatic_backup": None, - "last_completed_automatic_backup": None, - "schedule": {"state": "sat"}, }, - }, - ], -) -async def test_config_info( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - hass_storage: dict[str, Any], - storage_data: dict[str, Any] | None, -) -> None: - """Test getting backup config info.""" - hass_storage[DOMAIN] = { - "data": storage_data, - "key": DOMAIN, - "version": 1, - } - - await setup_backup_integration(hass) - await hass.async_block_till_done() - - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "backup/config/info"}) - assert await client.receive_json() == snapshot - - -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") -@pytest.mark.parametrize( - "command", - [ - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "mon", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "never", - }, - { - "type": "backup/config/update", - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": ["test-addon"], - "include_folders": ["media"], - "name": "test-name", - "password": "test-password", - }, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": 7}, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": None}, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": None}, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3}, - "schedule": "daily", - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"days": 7}, - "schedule": "daily", - }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": False}, + "test-agent2": {"protected": True}, + }, + }, + ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": False, + } + ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": True, + } + ], ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_update( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - command: dict[str, Any], + commands: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot - await client.send_json_auto_id(command) - result = await client.receive_json() + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] - assert result["success"] + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() - await client.send_json_auto_id({"type": "backup/config/info"}) - assert await client.receive_json() == snapshot + # Trigger store write + freezer.tick(60) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass_storage[DOMAIN] == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "command", [ { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "someday", + "recurrence": "blah", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": "never", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"state": "someday"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"time": "early"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"days": "mon"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "recurrence": {"days": ["fun"]}, }, { "type": "backup/config/update", @@ -1144,6 +1455,18 @@ async def test_config_update( "type": "backup/config/update", "create_backup": {"include_folders": ["media", "media"]}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"favorite": True}}, + }, + { + "type": "backup/config/update", + "retention": {"copies": 0}, + }, + { + "type": "backup/config/update", + "retention": {"days": 0}, + }, ], ) async def test_config_update_errors( @@ -1179,6 +1502,8 @@ async def test_config_update_errors( "time_2", "attempted_backup_time", "completed_backup_time", + "scheduled_backup_time", + "additional_backup", "backup_calls_1", "backup_calls_2", "call_args", @@ -1189,10 +1514,12 @@ async def test_config_update_errors( # No config update [], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1204,14 +1531,16 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1222,14 +1551,16 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-25T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-25T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1240,7 +1571,71 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "time": "03:45", + }, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-25T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-13T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"days": ["wed", "fri"], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-15T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + False, + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "never"}, } ], "2024-11-11T04:45:00+01:00", @@ -1248,6 +1643,8 @@ async def test_config_update_errors( "2034-11-11T13:00:00+01:00", "2024-11-11T04:45:00+01:00", "2024-11-11T04:45:00+01:00", + None, + False, 0, 0, None, @@ -1258,14 +1655,36 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"days": [], "recurrence": "custom_days"}, + } + ], + "2024-11-11T04:45:00+01:00", + "2034-11-11T12:00:00+01:00", # ten years later and still no backups + "2034-11-11T13:00:00+01:00", + "2024-11-11T04:45:00+01:00", + "2024-11-11T04:45:00+01:00", + None, + False, + 0, + 0, + None, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"recurrence": "daily"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1276,14 +1695,16 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", + True, 1, 1, BACKUP_CALL, @@ -1294,7 +1715,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], "2024-10-26T04:45:00+01:00", @@ -1302,6 +1723,8 @@ async def test_config_update_errors( "2034-11-12T12:00:00+01:00", "2024-10-26T04:45:00+01:00", "2024-10-26T04:45:00+01:00", + None, + False, 0, 0, None, @@ -1312,14 +1735,16 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1330,14 +1755,16 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + False, 1, 2, BACKUP_CALL, @@ -1345,6 +1772,7 @@ async def test_config_update_errors( ), ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_schedule_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1357,41 +1785,54 @@ async def test_config_schedule_logic( time_2: str, attempted_backup_time: str, completed_backup_time: str, + scheduled_backup_time: str, + additional_backup: bool, backup_calls_1: int, backup_calls_2: int, call_args: Any, create_backup_side_effect: list[Exception | None] | None, ) -> None: """Test config schedule logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { + "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test.test-agent"], - "include_addons": ["test-addon"], + "include_addons": [], "include_all_addons": False, "include_database": True, - "include_folders": ["media"], + "include_folders": [], "name": "test-name", "password": "test-password", }, "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": last_completed_automatic_backup, "last_completed_automatic_backup": last_completed_automatic_backup, - "schedule": {"state": "daily"}, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } create_backup.side_effect = create_backup_side_effect await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + await setup_backup_integration(hass, remote_agents=["test.test-agent"]) await hass.async_block_till_done() for command in commands: @@ -1399,6 +1840,11 @@ async def test_config_schedule_logic( result = await client.receive_json() assert result["success"] + await client.send_json_auto_id({"type": "backup/info"}) + result = await client.receive_json() + assert result["result"]["next_automatic_backup"] == scheduled_backup_time + assert result["result"]["next_automatic_backup_additional"] == additional_backup + freezer.move_to(time_1) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -1429,14 +1875,13 @@ async def test_config_schedule_logic( "command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "delete_backup_side_effects", "last_backup_time", "next_time", "backup_time", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -1444,25 +1889,29 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1475,33 +1924,36 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1514,23 +1966,24 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, @@ -1543,38 +1996,57 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1587,38 +2059,110 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", 1, - [call("backup-1")], + 1, + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1631,116 +2175,141 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 2, - [call("backup-1"), call("backup-2")], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), }, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, {}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), }, {}, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "retention": {"copies": 1, "days": None}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1753,23 +2322,67 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [call("backup-1"), call("backup-2"), call("backup-3")], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + }, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "retention": {"copies": 1, "days": None}, + "schedule": {"recurrence": "daily"}, }, { "backup-1": MagicMock( - date="2024-11-12T04:45:00+01:00", + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1782,36 +2395,76 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 1, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + {}, ), ], ) +@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) async def test_config_retention_copies_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + delete_backup_side_effects: dict[str, Exception], last_backup_time: str, next_time: str, backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { + "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -1824,22 +2477,32 @@ async def test_config_retention_copies_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "daily"}, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent", "test.test-agent2"] + ) await hass.async_block_till_done() + for agent_id, agent in mock_agents.items(): + agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id) + await client.send_json_auto_id(command) result = await client.receive_json() @@ -1850,8 +2513,10 @@ async def test_config_retention_copies_logic( await hass.async_block_till_done() assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in mock_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -1882,11 +2547,9 @@ async def test_config_retention_copies_logic( "config_command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -1894,154 +2557,164 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "never", + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), }, {}, - {}, 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), }, {}, + 1, + 1, {}, - 1, - 1, - 0, - [], ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), }, {}, - {}, 1, 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "never", + "schedule": {"recurrence": "never"}, }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), }, {}, - {}, 1, 1, - 2, - [call("backup-1"), call("backup-2")], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -2051,24 +2724,26 @@ async def test_config_retention_copies_logic_manual_backup( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, config_command: dict[str, Any], backup_command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic for manual backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { + "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2081,20 +2756,27 @@ async def test_config_retention_copies_logic_manual_backup( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "daily"}, + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent"] + ) await hass.async_block_till_done() await client.send_json_auto_id(config_command) @@ -2111,8 +2793,10 @@ async def test_config_retention_copies_logic_manual_backup( assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in mock_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2131,13 +2815,12 @@ async def test_config_retention_copies_logic_manual_backup( "commands", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "delete_backup_side_effects", "last_backup_time", "start_time", "next_time", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ # No config update - cleanup backups older than 2 days @@ -2146,16 +2829,19 @@ async def test_config_retention_copies_logic_manual_backup( [], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2167,8 +2853,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), # No config update - No cleanup ( @@ -2176,16 +2861,19 @@ async def test_config_retention_copies_logic_manual_backup( [], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2197,8 +2885,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 0, - 0, - [], + {}, ), # Unchanged config ( @@ -2208,21 +2895,24 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2234,8 +2924,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2244,21 +2933,24 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2270,8 +2962,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2280,21 +2971,24 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 3}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2306,8 +3000,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 0, - [], + {}, ), ( None, @@ -2316,26 +3009,30 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2347,8 +3044,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [call("backup-1"), call("backup-2")], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( None, @@ -2357,21 +3053,24 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2383,8 +3082,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2393,21 +3091,24 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2419,8 +3120,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1")], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2428,27 +3128,31 @@ async def test_config_retention_copies_logic_manual_backup( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 0}, - "schedule": "never", + "retention": {"copies": None, "days": 1}, + "schedule": {"recurrence": "never"}, } ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2460,8 +3164,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [call("backup-1"), call("backup-2")], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -2470,25 +3173,25 @@ async def test_config_retention_days_logic( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], - delete_backup: AsyncMock, get_backups: AsyncMock, stored_retained_days: int | None, commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + delete_backup_side_effects: dict[str, Exception], last_backup_time: str, start_time: str, next_time: str, get_backups_calls: int, - delete_calls: int, - delete_args_list: list[Any], + delete_calls: dict[str, Any], ) -> None: """Test config backup retention logic.""" client = await hass_ws_client(hass) storage_data = { - "backups": {}, + "backups": [], "config": { + "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2501,22 +3204,32 @@ async def test_config_retention_days_logic( "retention": {"copies": None, "days": stored_retained_days}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "never"}, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to(start_time) - await setup_backup_integration(hass) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent"] + ) await hass.async_block_till_done() + for agent_id, agent in mock_agents.items(): + agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id) + for command in commands: await client.send_json_auto_id(command) result = await client.receive_json() @@ -2526,12 +3239,193 @@ async def test_config_retention_days_logic( async_fire_time_changed(hass) await hass.async_block_till_done() assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in mock_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() +async def test_configured_agents_unavailable_repair( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test creating and deleting repair issue for configured unavailable agents.""" + issue_id = "automatic_backup_agents_unavailable_test.agent" + ws_client = await hass_ws_client(hass) + hass_storage.update( + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + } + ) + + await setup_backup_integration(hass) + get_agents_mock = AsyncMock(return_value=[mock_backup_agent("agent")]) + register_listener_mock = Mock() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=get_agents_mock, + async_register_backup_agents_listener=register_listener_mock, + ), + ) + await hass.async_block_till_done() + + reload_backup_agents = register_listener_mock.call_args[1]["listener"] + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + # Reload the agents with no agents returned. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"] + + # Update the automatic backup configuration removing the unavailable agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Reload the agents with one agent returned + # but not configured for automatic backups. + + get_agents_mock.return_value = [mock_backup_agent("agent")] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Update the automatic backup configuration and configure the test agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local", "test.agent"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Reload the agents with no agents returned again. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Update the automatic backup configuration removing all agents. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": []}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [] + + async def test_subscribe_event( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2549,6 +3443,103 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot manager.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) + ) + assert await client.receive_json() == snapshot + + +async def test_subscribe_event_early( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test subscribe event before backup integration has started.""" + async_initialize_backup(hass) + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + manager.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) + ) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + ("agent_id", "backup_id", "password"), + [ + # Invalid agent or backup + ("no_such_agent", "c0cb53bd", "hunter2"), + ("backup.local", "no_such_backup", "hunter2"), + # Legacy backup, which can't be streamed + ("backup.local", "2bcb3113", "hunter2"), + # New backup, which can be streamed, try with correct and wrong password + ("backup.local", "c0cb53bd", "hunter2"), + ("backup.local", "c0cb53bd", "wrong_password"), + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + agent_id: str, + backup_id: str, + password: str, +) -> None: + """Test can decrypt on download.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": backup_id, + "agent_id": agent_id, + "password": password, + } + ) + assert await client.receive_json() == snapshot + + +@pytest.mark.parametrize( + "error", + [ + BackupAgentError, + BackupNotFound, + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_with_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + error: Exception, +) -> None: + """Test can decrypt on download.""" + + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={"test.remote": [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], + ) + client = await hass_ws_client(hass) + + mock_agents["test.remote"].async_download_backup.side_effect = error + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } ) assert await client.receive_json() == snapshot diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 765801d22cf..14810f67ce3 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.baf.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_NAME, MOCK_UUID, MockBAFDevice @@ -90,7 +90,7 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -128,7 +128,7 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -148,7 +148,7 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -167,7 +167,7 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 0bb8b2cd468..18639b0c9be 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Generator +from datetime import time from unittest.mock import AsyncMock, MagicMock, patch from pybalboa.enums import HeatMode, LowHighRange @@ -48,7 +49,12 @@ def client_fixture() -> Generator[MagicMock]: client.blowers = [] client.circulation_pump.state = 0 client.filter_cycle_1_running = False + client.filter_cycle_1_start = time(8, 0) + client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_enabled = True + client.filter_cycle_2_start = time(19, 0) + client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 client.temperature = 10 client.temperature_minimum = 10 @@ -62,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]: client.pumps = [] client.temperature_range.state = LowHighRange.LOW + client.fault = None + yield client diff --git a/tests/components/balboa/snapshots/test_binary_sensor.ambr b/tests/components/balboa/snapshots/test_binary_sensor.ambr index c37c8a20d4b..4aa0f1d71fe 100644 --- a/tests/components/balboa/snapshots/test_binary_sensor.ambr +++ b/tests/components/balboa/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_climate.ambr b/tests/components/balboa/snapshots/test_climate.ambr index d3060077341..70e33c4065f 100644 --- a/tests/components/balboa/snapshots/test_climate.ambr +++ b/tests/components/balboa/snapshots/test_climate.ambr @@ -17,6 +17,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr new file mode 100644 index 00000000000..fc8f591a9fc --- /dev/null +++ b/tests/components/balboa/snapshots/test_event.ambr @@ -0,0 +1,90 @@ +# serializer version: 1 +# name: test_events[event.fakespa_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.fakespa_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'FakeSpa-fault-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[event.fakespa_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + 'friendly_name': 'FakeSpa Fault', + }), + 'context': , + 'entity_id': 'event.fakespa_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/balboa/snapshots/test_fan.ambr b/tests/components/balboa/snapshots/test_fan.ambr index 8d35ab6de7c..4df73c3178c 100644 --- a/tests/components/balboa/snapshots/test_fan.ambr +++ b/tests/components/balboa/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_light.ambr b/tests/components/balboa/snapshots/test_light.ambr index 31777744740..fdfd7af1d0c 100644 --- a/tests/components/balboa/snapshots/test_light.ambr +++ b/tests/components/balboa/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_select.ambr b/tests/components/balboa/snapshots/test_select.ambr index a0cfd68d009..68368bf3602 100644 --- a/tests/components/balboa/snapshots/test_select.ambr +++ b/tests/components/balboa/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr new file mode 100644 index 00000000000..ad63fcdf387 --- /dev/null +++ b/tests/components/balboa/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 enabled', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_2_enabled', + 'unique_id': 'FakeSpa-filter_cycle_2_enabled-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 enabled', + }), + 'context': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr new file mode 100644 index 00000000000..6b27717e2d3 --- /dev/null +++ b/tests/components/balboa/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_times[time.fakespa_filter_cycle_1_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_1_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '09:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_1_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_2_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21:30:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_2_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:00:00', + }) +# --- diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index afa170577df..d81edaad3b4 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -3,19 +3,23 @@ from unittest.mock import MagicMock, patch from pybalboa.exceptions import SpaConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -TEST_DATA = { - CONF_HOST: "1.1.1.1", -} -TEST_ID = "FakeBalboa" +TEST_HOST = "1.1.1.1" +TEST_DATA = {CONF_HOST: TEST_HOST} +TEST_MAC = "ef:ef:ef:c0:ff:ee" +TEST_DHCP_SERVICE_INFO = DhcpServiceInfo( + ip=TEST_HOST, macaddress=TEST_MAC.replace(":", ""), hostname="fakespa" +) async def test_form(hass: HomeAssistant, client: MagicMock) -> None: @@ -107,7 +111,7 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None: """Test when provided credentials are already configured.""" - MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -138,7 +142,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: """Test specifying non default settings using options flow.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID) + config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -161,3 +165,111 @@ async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert dict(config_entry.options) == {CONF_SYNC_TIME: True} + + +async def test_dhcp_discovery(hass: HomeAssistant, client: MagicMock) -> None: + """Test we can process the discovery from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "FakeSpa" + assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_MAC + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) + entry.add_to_hass(hass) + + updated_ip = "1.1.1.2" + TEST_DHCP_SERVICE_INFO.ip = updated_ip + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == updated_ip + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (SpaConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_dhcp_discovery_failed( + hass: HomeAssistant, client: MagicMock, side_effect: Exception, reason: str +) -> None: + """Test failed setup from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_dhcp_discovery_manual_user_setup( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery with manual user setup.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == TEST_DATA diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py new file mode 100644 index 00000000000..04f25f6cfa0 --- /dev/null +++ b/tests/components/balboa/test_event.py @@ -0,0 +1,82 @@ +"""Tests of the events of the balboa integration.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_EVENT = "event.fakespa_fault" +FAULT_DATE = "fault_date" + + +async def test_events( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa events.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_event(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa fault event.""" + await init_integration(hass) + + # check the state is unknown + state = hass.states.get(ENTITY_EVENT) + assert state.state == STATE_UNKNOWN + + # set a fault + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16 + ) + client.emit("") + await hass.async_block_till_done() + + # check new state is what we expect + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to None + client.fault = None + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to an unknown one + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1 + ) + # validate a ValueError is raises + with pytest.raises(ValueError): + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py new file mode 100644 index 00000000000..4b6bae172f4 --- /dev/null +++ b/tests/components/balboa/test_switch.py @@ -0,0 +1,55 @@ +"""Tests of the switches of the balboa integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform +from tests.components.switch import common + +ENTITY_SWITCH = "switch.fakespa_filter_cycle_2_enabled" + + +async def test_switches( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa switches.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.SWITCH]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_switch(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa filter cycle enabled switch.""" + await init_integration(hass) + + # check if the initial state is on + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_ON + + # test calling turn off + await common.async_turn_off(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=False) + + setattr(client, "filter_cycle_2_enabled", False) + client.emit("") + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_OFF + + # test calling turn on + await common.async_turn_on(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=True) diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py new file mode 100644 index 00000000000..21778d08e2d --- /dev/null +++ b/tests/components/balboa/test_time.py @@ -0,0 +1,72 @@ +"""Tests of the times of the balboa integration.""" + +from __future__ import annotations + +from datetime import time +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_TIME = "time.fakespa_" + + +async def test_times( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa times.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.TIME]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("filter_cycle", "period", "value"), + [ + (1, "start", "08:00:00"), + (1, "end", "09:00:00"), + (2, "start", "19:00:00"), + (2, "end", "21:30:00"), + ], +) +async def test_time( + hass: HomeAssistant, client: MagicMock, filter_cycle: int, period: str, value: str +) -> None: + """Test spa filter cycle time.""" + await init_integration(hass) + + time_entity = f"{ENTITY_TIME}filter_cycle_{filter_cycle}_{period}" + + # check the expected state of the time entity + state = hass.states.get(time_entity) + assert state.state == value + + new_time = time(hour=7, minute=0) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_TIME: new_time}, + blocking=True, + target={ATTR_ENTITY_ID: time_entity}, + ) + + # check we made a call with the right parameters + client.configure_filter_cycle.assert_called_with(filter_cycle, **{period: new_time}) diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 27292e5a28c..c21afb4a130 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -31,8 +31,8 @@ from homeassistant.components.bang_olufsen.const import ( CONF_BEOLINK_JID, BangOlufsenSource, ) -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "192.168.0.1" TEST_HOST_INVALID = "192.168.0" @@ -42,18 +42,18 @@ TEST_MODEL_CORE = "Beoconnect Core" TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" -TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" -TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}" TEST_FRIENDLY_NAME = "Living room Balance" TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" -TEST_FRIENDLY_NAME_2 = "Laundry room Balance" -TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com" -TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222" +TEST_FRIENDLY_NAME_2 = "Laundry room Core" +TEST_SERIAL_NUMBER_2 = "22222222" +TEST_NAME_2 = f"{TEST_MODEL_CORE}-{TEST_SERIAL_NUMBER_2}" +TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER_2}@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beoconnect_core_22222222" TEST_HOST_2 = "192.168.0.2" TEST_FRIENDLY_NAME_3 = "Lego room Balance" @@ -84,7 +84,7 @@ TEST_DATA_CREATE_ENTRY = { CONF_NAME: TEST_NAME, } TEST_DATA_CREATE_ENTRY_2 = { - CONF_HOST: TEST_HOST, + CONF_HOST: TEST_HOST_2, CONF_MODEL: TEST_MODEL_CORE, CONF_BEOLINK_JID: TEST_JID_2, CONF_NAME: TEST_NAME_2, diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index e9540b5cec6..bc51f89f96d 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -1,6 +1,22 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics dict({ + 'PlayPause_event': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'short_press_release', + 'long_press_timeout', + 'long_press_release', + 'very_long_press_timeout', + 'very_long_press_release', + ]), + 'friendly_name': 'Living room Balance Play / Pause', + }), + 'entity_id': 'event.beosound_balance_11111111_play_pause', + 'state': 'unknown', + }), 'config_entry': dict({ 'data': dict({ 'host': '192.168.0.1', @@ -18,6 +34,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Beosound Balance-11111111', 'unique_id': '11111111', 'version': 1, diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr new file mode 100644 index 00000000000..3b748d3a27a --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_button_event_creation + list([ + 'event.beosound_balance_11111111_bluetooth', + 'event.beosound_balance_11111111_microphone', + 'event.beosound_balance_11111111_next', + 'event.beosound_balance_11111111_play_pause', + 'event.beosound_balance_11111111_favourite_1', + 'event.beosound_balance_11111111_favourite_2', + 'event.beosound_balance_11111111_favourite_3', + 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_previous', + 'event.beosound_balance_11111111_volume', + 'media_player.beosound_balance_11111111', + ]) +# --- +# name: test_button_event_creation_beoconnect_core + list([ + 'media_player.beoconnect_core_22222222', + ]) +# --- diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index 327b7ecfacf..be7989a2cb9 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -642,7 +642,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -661,7 +661,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -737,7 +737,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -756,7 +756,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -831,7 +831,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -850,7 +850,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -924,7 +924,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -943,7 +943,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1003,7 +1003,7 @@ 'attributes': ReadOnlyDict({ 'beolink': dict({ 'leader': dict({ - 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + 'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com', }), 'peers': dict({ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', @@ -1017,7 +1017,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'media_player.beosound_balance_11111111', ]), 'media_content_type': , @@ -1062,7 +1062,7 @@ 'entity_picture_local': None, 'friendly_name': 'Living room Balance', 'group_members': list([ - 'media_player.beosound_balance_22222222', + 'media_player.beoconnect_core_22222222', 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), @@ -1081,7 +1081,7 @@ 'supported_features': , }), 'context': , - 'entity_id': 'media_player.beosound_balance_22222222', + 'entity_id': 'media_player.beoconnect_core_22222222', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index 7c99648ace4..a9415a222a8 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -6,6 +6,9 @@ from syrupy import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import TEST_BUTTON_EVENT_ENTITY_ID from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -14,6 +17,7 @@ from tests.typing import ClientSessionGenerator async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, + entity_registry: EntityRegistry, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, @@ -23,6 +27,10 @@ async def test_async_get_config_entry_diagnostics( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + # Enable an Event entity + entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) + hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index d58e5d2219b..855dab40db1 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock from inflection import underscore from mozart_api.models import ButtonEvent +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bang_olufsen.const import ( DEVICE_BUTTON_EVENTS, @@ -25,6 +26,7 @@ async def test_button_event_creation( mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test button event entities are created.""" @@ -35,14 +37,21 @@ async def test_button_event_creation( # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "preset_" + "preset", "favourite_" ) for button_type in DEVICE_BUTTONS ] # Check that the entities are available for entity_id in entity_ids: - entity_registry.async_get(entity_id) + assert entity_registry.async_get(entity_id) + + # Check number of entities + # The media_player entity and all of the button event entities should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 + len(entity_ids) + + assert entity_ids_available == snapshot async def test_button_event_creation_beoconnect_core( @@ -50,6 +59,7 @@ async def test_button_event_creation_beoconnect_core( mock_config_entry_core: MockConfigEntry, mock_mozart_client: AsyncMock, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test button event entities are not created when using a Beoconnect Core.""" @@ -57,17 +67,12 @@ async def test_button_event_creation_beoconnect_core( mock_config_entry_core.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry_core.entry_id) - # Add Button Event entity ids - entity_ids = [ - f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "preset_" - ) - for button_type in DEVICE_BUTTONS - ] + # Check number of entities + # The media_player entity should be the only available + entity_ids_available = list(entity_registry.entities.keys()) + assert len(entity_ids_available) == 1 - # Check that the entities are unavailable - for entity_id in entity_ids: - assert not entity_registry.async_get(entity_id) + assert entity_ids_available == snapshot async def test_button( diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 8a0132ff2af..59fbdf9a253 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 78e382f77bf..dd71c1e5d06 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 26b8d919d72..de2b2565fe1 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import binary_sensor from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import MockBinarySensor @@ -102,7 +102,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) @@ -172,7 +172,7 @@ async def test_entity_category_config_raises_error( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2]) diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py index db92dddcc77..5de41a1fb1e 100644 --- a/tests/components/blackbird/test_media_player.py +++ b/tests/components/blackbird/test_media_player.py @@ -10,7 +10,6 @@ from homeassistant.components.blackbird.const import DOMAIN, SERVICE_SETALLZONES from homeassistant.components.blackbird.media_player import ( DATA_BLACKBIRD, PLATFORM_SCHEMA, - setup_platform, ) from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -18,6 +17,9 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockEntityPlatform class AttrDict(dict): @@ -181,21 +183,21 @@ async def setup_blackbird(hass: HomeAssistant, mock_blackbird: MockBlackbird) -> "homeassistant.components.blackbird.media_player.get_blackbird", return_value=mock_blackbird, ): - await hass.async_add_executor_job( - setup_platform, + await async_setup_component( hass, + "media_player", { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "zones": {3: {"name": "Zone name"}}, - "sources": { - 1: {"name": "one"}, - 3: {"name": "three"}, - 2: {"name": "two"}, - }, + "media_player": { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "zones": {3: {"name": "Zone name"}}, + "sources": { + 1: {"name": "one"}, + 3: {"name": "three"}, + 2: {"name": "two"}, + }, + } }, - lambda *args, **kwargs: None, - {}, ) await hass.async_block_till_done() @@ -207,6 +209,7 @@ def media_player_entity( """Return the media player entity.""" media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] media_player.hass = hass + media_player.platform = MockEntityPlatform(hass) media_player.entity_id = "media_player.zone_3" return media_player @@ -271,10 +274,6 @@ async def test_update( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test updating values from blackbird.""" - assert media_player_entity.state is None - assert media_player_entity.source is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON assert media_player_entity.source == "one" @@ -291,9 +290,6 @@ async def test_state( mock_blackbird: MockBlackbird, ) -> None: """Test state property.""" - assert media_player_entity.state is None - - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.state == STATE_ON mock_blackbird.zones[3].power = False @@ -315,8 +311,6 @@ async def test_source( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test source property.""" - assert media_player_entity.source is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.source == "one" @@ -324,8 +318,6 @@ async def test_media_title( hass: HomeAssistant, media_player_entity: MediaPlayerEntity ) -> None: """Test media title property.""" - assert media_player_entity.media_title is None - await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.media_title == "one" diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 612c4f09424..4b0c1b23e79 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -7,12 +7,12 @@ import blebox_uniapi import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.blebox import config_flow from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .conftest import mock_config, mock_feature, mock_only_feature, setup_product_mock @@ -227,7 +227,7 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -267,7 +267,7 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -291,7 +291,7 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, @@ -317,7 +317,7 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("172.100.123.4"), ip_addresses=[ip_address("172.100.123.4")], port=80, diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index edc2879a66b..54df2b48cdb 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -48,6 +48,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 3, diff --git a/tests/components/bluemaestro/snapshots/test_sensor.ambr b/tests/components/bluemaestro/snapshots/test_sensor.ambr index 2b777ec6f09..48f20aa97b5 100644 --- a/tests/components/bluemaestro/snapshots/test_sensor.ambr +++ b/tests/components/bluemaestro/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/blueprint/test_default_blueprints.py b/tests/components/blueprint/test_default_blueprints.py index f69126a7f25..fbbd48eedd3 100644 --- a/tests/components/blueprint/test_default_blueprints.py +++ b/tests/components/blueprint/test_default_blueprints.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, models from homeassistant.components.blueprint.const import BLUEPRINT_FOLDER -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util DOMAINS = ["automation"] LOGGER = logging.getLogger(__name__) @@ -25,5 +25,5 @@ def test_default_blueprints(domain: str) -> None: for fil in items: LOGGER.info("Processing %s", fil) assert fil.name.endswith(".yaml") - data = yaml.load_yaml(fil) + data = yaml_util.load_yaml(fil) models.Blueprint(data, expected_domain=domain, schema=BLUEPRINT_SCHEMA) diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 3e644d3038a..f71302f286d 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -9,7 +9,6 @@ 'media_artist': 'artist', 'media_content_type': , 'media_duration': 123, - 'media_position': 2, 'media_title': 'song', 'shuffle': False, 'source_list': list([ diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index a1d67c120db..d0e0f75991b 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError from homeassistant.components.bluesound.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import PlayerMocks diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index a43696a0a7f..ed537d0bc57 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -127,7 +127,9 @@ async def test_attributes_set( ) -> None: """Test the media player attributes set.""" state = hass.states.get("media_player.player_name1111") - assert state == snapshot(exclude=props("media_position_updated_at")) + assert state == snapshot( + exclude=props("media_position_updated_at", "media_position") + ) async def test_stop_maps_to_idle( diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 8794d808718..31d301e2dac 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -10,32 +10,35 @@ from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS -from habluetooth import BaseHaScanner, BluetoothManager, get_manager +from habluetooth import BaseHaScanner, get_manager from homeassistant.components.bluetooth import ( DOMAIN, + MONOTONIC_TIME, SOURCE_LOCAL, + BaseHaRemoteScanner, BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry __all__ = ( + "MockBleakClient", + "generate_advertisement_data", + "generate_ble_device", "inject_advertisement", "inject_advertisement_with_source", "inject_advertisement_with_time_and_source", "inject_advertisement_with_time_and_source_connectable", "inject_bluetooth_service_info", "patch_all_discovered_devices", - "patch_discovered_devices", - "generate_advertisement_data", - "generate_ble_device", - "MockBleakClient", "patch_bluetooth_time", + "patch_discovered_devices", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -55,6 +58,11 @@ BLE_DEVICE_DEFAULTS = { } +HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00" +HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11" +NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF" + + @contextmanager def patch_bluetooth_time(mock_time: float) -> None: """Patch the bluetooth time.""" @@ -99,9 +107,10 @@ def generate_ble_device( return BLEDevice(**new) -def _get_manager() -> BluetoothManager: +def _get_manager() -> HomeAssistantBluetoothManager: """Return the bluetooth manager.""" - return get_manager() + manager: HomeAssistantBluetoothManager = get_manager() + return manager def inject_advertisement( @@ -324,3 +333,26 @@ class FakeScanner(FakeScannerMixin, BaseHaScanner): ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" return {} + + +class FakeRemoteScanner(BaseHaRemoteScanner): + """Fake remote scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 93a1c59cba1..e07b580acb2 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -5,9 +5,20 @@ from unittest.mock import patch from bleak_retry_connector import bleak_manager from dbus_fast.aio import message_bus +from habluetooth import BaseHaRemoteScanner import habluetooth.util as habluetooth_utils import pytest +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, +) + @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): @@ -304,3 +315,37 @@ def disable_new_discovery_flows_fixture(): "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" ) as mock_create_flow: yield mock_create_flow + + +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci0 scanner.""" + hci0_scanner = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0") + hci0_scanner.connectable = True + cancel = bluetooth.async_register_scanner(hass, hci0_scanner, connection_slots=5) + yield + cancel() + bluetooth.async_remove_scanner(hass, hci0_scanner.source) + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci1 scanner.""" + hci1_scanner = FakeScanner(HCI1_SOURCE_ADDRESS, "hci1") + hci1_scanner.connectable = True + cancel = bluetooth.async_register_scanner(hass, hci1_scanner, connection_slots=5) + yield + cancel() + bluetooth.async_remove_scanner(hass, hci1_scanner.source) + + +@pytest.fixture +def register_non_connectable_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an non connectable remote scanner.""" + remote_scanner = BaseHaRemoteScanner( + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, "non connectable", None, False + ) + cancel = bluetooth.async_register_scanner(hass, remote_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, remote_scanner.source) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index abfbbaa15ab..acd630863d2 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -7,16 +7,12 @@ import time from typing import Any from unittest.mock import patch -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData - # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( - MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, storage, @@ -28,12 +24,16 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads from . import ( + FakeRemoteScanner as FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data, @@ -41,30 +41,7 @@ from . import ( patch_bluetooth_time, ) -from tests.common import async_fire_time_changed, load_fixture - - -class FakeScanner(BaseHaRemoteScanner): - """Fake scanner.""" - - def inject_advertisement( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - now: float | None = None, - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - now or MONOTONIC_TIME(), - ) +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @pytest.mark.parametrize("name_2", [None, "w"]) @@ -545,3 +522,75 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: cancel() unsetup() + + +@pytest.mark.usefixtures("enable_bluetooth") +@pytest.mark.parametrize( + ("manufacturer", "source"), + [ + ("test", "test"), + ("Raspberry Pi Trading Ltd (test)", "28:CD:C1:11:23:45"), + ], +) +async def test_remote_scanner_bluetooth_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + manufacturer: str, + source: str, +) -> None: + """Test the remote scanner gets a bluetooth config entry.""" + manager: HomeAssistantBluetoothManager = _get_manager() + + switchbot_device = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner(source, source, connector, True) + unsetup = scanner.async_setup() + assert scanner.source == source + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + cancel = manager.async_register_hass_scanner( + scanner, + source_domain="test", + source_model="test", + source_config_entry_id=entry.entry_id, + ) + await hass.async_block_till_done() + + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + assert len(scanner.discovered_devices) == 1 + + cancel() + unsetup() + + adapter_entry = hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + assert adapter_entry is not None + assert adapter_entry.state is ConfigEntryState.LOADED + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert dev is not None + assert dev.config_entries == {adapter_entry.entry_id} + assert dev.manufacturer == manufacturer + + manager.async_remove_scanner(scanner.source) + await hass.async_block_till_done() + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 0a0cb3fa8e0..45d177de132 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -6,16 +6,25 @@ from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails import pytest from homeassistant import config_entries +from homeassistant.components.bluetooth import HaBluetoothConnector from homeassistant.components.bluetooth.const import ( CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DEVICE_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.setup import async_setup_component +from . import FakeRemoteScanner, MockBleakClient, _get_manager + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -450,9 +459,68 @@ async def test_options_flow_enabled_linux( await hass.config_entries.async_unload(entry.entry_id) +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: + """Test options are not available for remote adapters.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "BB:BB:BB:BB:BB:BB", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "remote_adapters_not_supported" + + +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> None: + """Test options are not available for local adapters without passive support.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + _get_manager()._adapters["hci0"]["passive_scan"] = False + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "local_adapters_no_passive_support" + + @pytest.mark.usefixtures("one_adapter") -async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: - """Test we give a hint that the adapter is ignored.""" +async def test_async_step_user_linux_adapter_replace_ignored( + hass: HomeAssistant, +) -> None: + """Test we can replace an ignored adapter from user flow.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="00:00:00:00:00:01", @@ -464,6 +532,116 @@ async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> context={"source": config_entries.SOURCE_USER}, data={}, ) + with ( + patch("homeassistant.components.bluetooth.async_setup", return_value=True), + patch( + "homeassistant.components.bluetooth.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + assert result2["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter configuration via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + manager = _get_manager() + area_entry = area_registry.async_get_or_create("test") + cancel_scanner = manager.async_register_scanner(scanner) + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "BB:BB:BB:BB:BB:BB")}, + suggested_area=area_entry.id, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: device_entry.id, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "esp32" + assert result["data"] == { + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: device_entry.id, + } + await hass.async_block_till_done() + + new_entry_id: str = result["result"].entry_id + new_entry = hass.config_entries.async_get_entry(new_entry_id) + assert new_entry is not None + assert new_entry.state is config_entries.ConfigEntryState.LOADED + + ble_device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, scanner.source)} + ) + assert ble_device_entry is not None + assert ble_device_entry.via_device_id == device_entry.id + assert ble_device_entry.area_id == area_entry.id + + await hass.config_entries.async_unload(new_entry.entry_id) + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + cancel_scanner() + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter_mac_fix( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter corrects mac address via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + bluetooth_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "AA:BB:CC:DD:EE:FF", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + bluetooth_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: "AA:AA:AA:AA:AA:AA", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_adapters" - assert result["description_placeholders"] == {"ignored_adapters": "1"} + assert result["reason"] == "already_configured" + assert bluetooth_entry.unique_id == "AA:AA:AA:AA:AA:AA" + assert bluetooth_entry.data[CONF_SOURCE] == "AA:AA:AA:AA:AA:AA" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index be4412db4d8..e38ae19ce52 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,6 +133,20 @@ async def test_diagnostics( } }, "manager": { + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + "00:00:00:00:00:02": { + "allocated": [], + "free": 2, + "slots": 2, + "source": "00:00:00:00:00:02", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -168,6 +182,7 @@ async def test_diagnostics( "scanners": [ { "adapter": "hci0", + "connectable": True, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, @@ -204,6 +219,7 @@ async def test_diagnostics( "rssi": -127, } ], + "connectable": True, "last_detection": ANY, "monotonic_time": ANY, "name": "hci1 (00:00:00:00:00:02)", @@ -291,6 +307,14 @@ async def test_diagnostics_macos( } }, "manager": { + "allocations": { + "Core Bluetooth": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "Core Bluetooth", + }, + }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -369,6 +393,7 @@ async def test_diagnostics_macos( "scanners": [ { "adapter": "Core Bluetooth", + "connectable": True, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -484,6 +509,14 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -563,6 +596,7 @@ async def test_diagnostics_remote_adapter( "scanners": [ { "adapter": "hci0", + "connectable": True, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, @@ -582,6 +616,8 @@ async def test_diagnostics_remote_adapter( }, { "connectable": True, + "current_mode": None, + "requested_mode": None, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index ba8792a79a3..de299c58b93 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth import ( BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, + HaBluetoothConnector, async_process_advertisements, async_rediscover_address, async_track_unavailable, @@ -25,11 +26,16 @@ from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_PASSIVE, + CONF_SOURCE, + CONF_SOURCE_CONFIG_ENTRY_ID, + CONF_SOURCE_DOMAIN, + CONF_SOURCE_MODEL, DOMAIN, LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.components.bluetooth.match import ( ADDRESS, CONNECTABLE, @@ -46,7 +52,9 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + FakeRemoteScanner, FakeScanner, + MockBleakClient, _get_manager, async_setup_with_default_adapter, async_setup_with_one_adapter, @@ -3022,6 +3030,23 @@ async def test_scanner_count_connectable(hass: HomeAssistant) -> None: cancel() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_remove(hass: HomeAssistant) -> None: + """Test permanently removing a scanner.""" + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) + assert bluetooth.async_scanner_count(hass, connectable=True) == 1 + device = generate_ble_device("44:44:33:11:23:45", "name") + adv = generate_advertisement_data(local_name="name", service_uuids=[]) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, time.monotonic(), scanner.source, True + ) + cancel() + bluetooth.async_remove_scanner(hass, scanner.source) + manager: HomeAssistantBluetoothManager = _get_manager() + assert not manager.storage.async_get_advertisement_history(scanner.source) + + @pytest.mark.usefixtures("enable_bluetooth") async def test_scanner_count(hass: HomeAssistant) -> None: """Test getting the connectable and non-connectable scanner count.""" @@ -3245,3 +3270,82 @@ async def test_title_updated_if_mac_address( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.title == "ACME Bluetooth Adapter 5.0 (00:00:00:00:00:01)" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_cleanup_orphened_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries get cleaned up when orphened.""" + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("esp32", "esp32", connector, True) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: "no_longer_exists", + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Orphened remote scanner config entry should be cleaned up + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_fix_incorrect_mac_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries can replace a incorrect mac.""" + source_entry = MockConfigEntry(domain="test") + source_entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:FF", "esp32", connector, True) + assert scanner.source == "AA:BB:CC:DD:EE:FF" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + await hass.config_entries.async_unload(entry.entry_id) + + new_scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:AA", "esp32", connector, True) + assert new_scanner.source == "AA:BB:CC:DD:EE:AA" + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_SOURCE: new_scanner.source}, + unique_id=new_scanner.source, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", new_scanner.source + ) + # Incorrect connection should be removed + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 0454df9a4a7..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,6 +1,5 @@ """Tests for the Bluetooth integration manager.""" -from collections.abc import Generator from datetime import timedelta import time from typing import Any @@ -8,6 +7,7 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from freezegun import freeze_time # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -36,13 +36,17 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, FakeScanner, MockBleakClient, _get_manager, @@ -63,24 +67,6 @@ from tests.common import ( ) -@pytest.fixture -def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) - yield - cancel() - - -@pytest.fixture -def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) - yield - cancel() - - @pytest.mark.usefixtures("enable_bluetooth") async def test_advertisements_do_not_switch_adapters_for_no_reason( hass: HomeAssistant, @@ -98,7 +84,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS ) assert ( @@ -113,7 +99,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_signal_99", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_99, switchbot_adv_signal_99, "hci0" + hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS ) assert ( @@ -128,7 +114,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_good_signal", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_98, switchbot_adv_signal_98, "hci1" + hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS ) # should not switch to hci1 @@ -153,7 +139,10 @@ async def test_switching_adapters_based_on_rssi( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) assert ( @@ -166,7 +155,10 @@ async def test_switching_adapters_based_on_rssi( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + HCI1_SOURCE_ADDRESS, ) assert ( @@ -175,7 +167,10 @@ async def test_switching_adapters_based_on_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_good_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -191,7 +186,10 @@ async def test_switching_adapters_based_on_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" + hass, + switchbot_device_similar_signal, + switchbot_adv_similar_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -214,7 +212,7 @@ async def test_switching_adapters_based_on_zero_rssi( local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) inject_advertisement_with_source( - hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0" + hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( @@ -227,7 +225,10 @@ async def test_switching_adapters_based_on_zero_rssi( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + HCI1_SOURCE_ADDRESS, ) assert ( @@ -236,7 +237,7 @@ async def test_switching_adapters_based_on_zero_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_no_rssi, "hci0" + hass, switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -252,7 +253,10 @@ async def test_switching_adapters_based_on_zero_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" + hass, + switchbot_device_similar_signal, + switchbot_adv_similar_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -282,7 +286,7 @@ async def test_switching_adapters_based_on_stale( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, - "hci0", + HCI0_SOURCE_ADDRESS, ) assert ( @@ -301,7 +305,7 @@ async def test_switching_adapters_based_on_stale( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale @@ -349,7 +353,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, - "hci0", + HCI0_SOURCE_ADDRESS, ) assert ( @@ -370,7 +374,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale @@ -384,7 +388,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + 1, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch yet since we are not within the @@ -399,7 +403,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should switch to hci1 since the previous advertisement is stale # even though the signal is poor because the device is now @@ -420,7 +424,9 @@ async def test_restore_history_from_dbus( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + "hci0", ) } @@ -432,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") @@ -456,7 +464,9 @@ async def test_restore_history_from_dbus_and_remote_adapters( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -496,7 +506,9 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -527,7 +539,12 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + now, + HCI0_SOURCE_ADDRESS, + True, ) assert ( @@ -623,7 +640,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ switchbot_device_good_signal, switchbot_adv_good_signal, now, - "hci1", + HCI1_SOURCE_ADDRESS, False, ) @@ -638,7 +655,12 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + now, + HCI0_SOURCE_ADDRESS, + True, ) assert ( @@ -678,7 +700,10 @@ async def test_switching_adapters_when_one_goes_away( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options @@ -690,7 +715,10 @@ async def test_switching_adapters_when_one_goes_away( cancel_hci2() inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # Now that hci2 is gone, we should prefer the poor signal @@ -729,7 +757,10 @@ async def test_switching_adapters_when_one_stop_scanning( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options @@ -741,7 +772,10 @@ async def test_switching_adapters_when_one_stop_scanning( hci2_scanner.scanning = False inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # Now that hci2 has stopped scanning, we should prefer the poor signal @@ -1660,3 +1694,71 @@ async def test_bluetooth_rediscover_no_match( cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_register_disappeared_callback( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test bluetooth async_register_disappeared_callback handles failures.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + failed_disappeared: list[str] = [] + + def _failing_callback(_address: str) -> None: + """Failing callback.""" + failed_disappeared.append(_address) + raise ValueError("This is a test") + + ok_disappeared: list[str] = [] + + def _ok_callback(_address: str) -> None: + """Ok callback.""" + ok_disappeared.append(_address) + + manager: HomeAssistantBluetoothManager = _get_manager() + cancel1 = manager.async_register_disappeared_callback(_failing_callback) + # Make sure the second callback still works if the first one fails and + # raises an exception + cancel2 = manager.async_register_disappeared_callback(_ok_callback) + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + + assert len(ok_disappeared) == 1 + assert ok_disappeared[0] == address + assert len(failed_disappeared) == 1 + assert failed_disappeared[0] == address + + cancel1() + cancel2() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index d7a7a8ba08c..e9274965e3c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1808,6 +1808,7 @@ async def test_naming(hass: HomeAssistant) -> None: sensor_entity: PassiveBluetoothProcessorEntity = sensor_entities[0] sensor_entity.hass = hass + sensor_entity.platform = MockEntityPlatform(hass) assert sensor_entity.available is True assert sensor_entity.name is UNDEFINED assert sensor_entity.device_class is SensorDeviceClass.TEMPERATURE diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py new file mode 100644 index 00000000000..57199d04078 --- /dev/null +++ b/tests/components/bluetooth/test_websocket_api.py @@ -0,0 +1,432 @@ +"""The tests for the bluetooth WebSocket API.""" + +import asyncio +from datetime import timedelta +import time +from unittest.mock import ANY, patch + +from bleak_retry_connector import Allocations +from freezegun import freeze_time +import pytest + +from homeassistant.components.bluetooth import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, + _get_manager, + generate_advertisement_data, + generate_ble_device, + inject_advertisement_with_source, +) + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_advertisements( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_advertisements.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_advertisements", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {}, + "name": "wohand_signal_100", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": HCI0_SOURCE_ADDRESS, + "time": ANY, + "tx_power": -127, + } + ] + } + adv_time = response["event"]["add"][0]["time"] + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI1_SOURCE_ADDRESS + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {"123": "616263"}, + "name": "wohand_signal_100", + "rssi": -80, + "service_data": {}, + "service_uuids": [], + "source": HCI1_SOURCE_ADDRESS, + "time": ANY, + "tx_power": -127, + } + ] + } + new_time = response["event"]["add"][0]["time"] + assert new_time > adv_time + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]} + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_connection_allocations( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + register_non_connectable_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == [ + { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI0_SOURCE_ADDRESS, + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI1_SOURCE_ADDRESS, + }, + { + "allocated": [], + "free": 0, + "slots": 0, + "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + }, + ] + + manager = _get_manager() + manager.async_on_allocation_changed( + Allocations( + adapter="hci1", # Will be translated to source + slots=5, + free=4, + allocated=["AA:BB:CC:DD:EE:EE"], + ) + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == [ + { + "allocated": ["AA:BB:CC:DD:EE:EE"], + "free": 4, + "slots": 5, + "source": "AA:BB:CC:DD:EE:11", + }, + ] + manager.async_on_allocation_changed( + Allocations( + adapter="hci1", # Will be translated to source + slots=5, + free=5, + allocated=[], + ) + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == [ + {"allocated": [], "free": 5, "slots": 5, "source": HCI1_SOURCE_ADDRESS} + ] + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_connection_allocations_specific_scanner( + hass: HomeAssistant, + register_non_connectable_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for a specific source address.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id=NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS + ) + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == [ + { + "allocated": [], + "free": 0, + "slots": 0, + "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + } + ] + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_connection_allocations_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Config entry non_existent not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_connection_allocations_invalid_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for an invalid source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="invalid") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_source" + assert response["error"]["message"] == "Source invalid not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == { + "add": [ + { + "adapter": "hci0", + "connectable": False, + "name": "hci0 (00:00:00:00:00:01)", + "source": "00:00:00:00:00:01", + } + ] + } + + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_specific_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for a specific source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Invalid config entry id: non_existent" diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index da90980640b..738cae90c22 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -215,7 +215,7 @@ async def test_see_device_if_time_updated(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") async def test_preserve_new_tracked_device_name(hass: HomeAssistant) -> None: - """Test preserving tracked device name across new seens.""" + """Test preserving tracked device name across new seens.""" # codespell:ignore seens address = "DE:AD:BE:EF:13:37" name = "Mock device name" diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index c437e1d3669..2cd65364604 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -53,6 +53,13 @@ REMOTE_SERVICE_EXC_TRANSLATION = ( "Error executing remote service on vehicle. HTTPStatusError: 502 Bad Gateway" ) +BIMMER_CONNECTED_LOGIN_PATCH = ( + "homeassistant.components.bmw_connected_drive.config_flow.MyBMWAuthentication.login" +) +BIMMER_CONNECTED_VEHICLE_PATCH = ( + "homeassistant.components.bmw_connected_drive.coordinator.MyBMWAccount.get_vehicles" +) + async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" diff --git a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr index c0462279e59..569d39c1a5a 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +204,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -248,6 +253,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,6 +308,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -348,6 +355,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -397,6 +405,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -444,6 +453,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +502,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -550,6 +561,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -597,6 +609,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -645,6 +658,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -698,6 +712,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -744,6 +759,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -796,6 +812,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -843,6 +860,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -891,6 +909,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -949,6 +968,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -996,6 +1016,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1044,6 +1065,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1098,6 +1120,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1144,6 +1167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1196,6 +1220,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1245,6 +1270,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1306,6 +1332,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1354,6 +1381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1407,6 +1435,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr index f38441125ce..5072b918d2e 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -466,6 +476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -604,6 +617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -650,6 +664,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -696,6 +711,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -742,6 +758,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,6 +805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -834,6 +852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr index 395c6e56dda..3dc4e59b7b1 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr index 71dbc46b454..866e52e7982 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index b827dfe478a..de76b07057e 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -79,6 +80,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +149,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +217,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +286,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index 624b2c6007f..230025fc865 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -166,6 +169,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +231,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -279,6 +284,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -387,6 +394,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -441,6 +449,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -494,6 +503,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -602,6 +613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -654,6 +666,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -705,6 +718,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -752,6 +766,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -814,6 +829,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +891,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -933,6 +950,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -989,6 +1007,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1046,6 +1065,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1123,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1160,6 +1181,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1239,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1271,6 +1294,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1328,6 +1352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1385,6 +1410,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1442,6 +1468,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1499,6 +1526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1553,6 +1581,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1607,6 +1636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1659,6 +1689,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1710,6 +1741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1757,6 +1789,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1819,6 +1852,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1880,6 +1914,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1973,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1994,6 +2030,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2051,6 +2088,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2108,6 +2146,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2165,6 +2204,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2222,6 +2262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2276,6 +2317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2333,6 +2375,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2390,6 +2433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2447,6 +2491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2504,6 +2549,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2558,6 +2604,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2612,6 +2659,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2672,6 +2720,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2728,6 +2777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2785,6 +2835,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2842,6 +2893,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2899,6 +2951,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2956,6 +3009,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3010,6 +3064,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3067,6 +3122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3124,6 +3180,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3181,6 +3238,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3238,6 +3296,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3292,6 +3351,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3345,6 +3405,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3399,6 +3460,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index 5b60a32c3be..ce6ebc21f51 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9c124261392..2d4b1390ccc 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -15,11 +15,13 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + BIMMER_CONNECTED_LOGIN_PATCH, + BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CAPTCHA_INPUT, FIXTURE_CONFIG_ENTRY, FIXTURE_GCID, @@ -40,97 +42,11 @@ def login_sideeffect(self: MyBMWAuthentication): self.gcid = FIXTURE_GCID -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_authentication_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW authentication error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAuthError("Login failed"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on MyBMW API error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=RequestError("Connection reset"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_api_error(hass: HomeAssistant) -> None: - """Test we show user form on general connection error.""" - - with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=MyBMWAPIError("400 Bad Request"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - -@pytest.mark.usefixtures("bmw_fixture") -async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: - """Test the external flow with captcha failing once and succeeding the second time.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=deepcopy(FIXTURE_USER_INPUT), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "captcha" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_CAPTCHA_TOKEN: " "} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "missing_captcha"} - - async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: """Test registering an integration and finishing flow works.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -155,15 +71,125 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (MyBMWAuthError("Login failed"), "invalid_auth"), + (RequestError("Connection reset"), "cannot_connect"), + (MyBMWAPIError("400 Bad Request"), "cannot_connect"), + ], +) +async def test_error_display_with_successful_login( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we show user form on MyBMW authentication error and are still able to succeed.""" + + with patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_CAPTCHA_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME] + assert result["data"] == FIXTURE_COMPLETE_ENTRY + assert ( + result["result"].unique_id + == f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_USERNAME]}" + ) + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_id_existing(hass: HomeAssistant) -> None: + """Test registering an integration and when the unique id already exists.""" + + mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + mock_config_entry.add_to_hass(hass) + + with ( + patch( + BIMMER_CONNECTED_LOGIN_PATCH, + side_effect=login_sideeffect, + autospec=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=deepcopy(FIXTURE_USER_INPUT), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "captcha" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CAPTCHA_TOKEN: " "} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_captcha"} + + async def test_options_flow_implementation(hass: HomeAssistant) -> None: """Test config flow options.""" with ( patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ), patch( @@ -200,7 +226,7 @@ async def test_reauth(hass: HomeAssistant) -> None: """Test the reauth form.""" with ( patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ), @@ -249,7 +275,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_reconfigure(hass: HomeAssistant) -> None: """Test the reconfiguration form.""" with patch( - "bimmer_connected.api.authentication.MyBMWAuthentication.login", + BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True, ): diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index beb3d74d572..2e317ec1334 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,7 +1,6 @@ -"""Test BMW coordinator.""" +"""Test BMW coordinator for general availability/unavailability of entities and raising issues.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import patch from bimmer_connected.models import ( @@ -13,27 +12,56 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.bmw_connected_drive.const import ( + CONF_REFRESH_TOKEN, + SCAN_INTERVALS, +) from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.update_coordinator import UpdateFailed -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry, async_fire_time_changed +FIXTURE_ENTITY_STATES = { + "binary_sensor.m340i_xdrive_door_lock_state": "off", + "lock.m340i_xdrive_lock": "locked", + "lock.i3_rex_lock": "unlocked", + "number.ix_xdrive50_target_soc": "80", + "sensor.ix_xdrive50_rear_left_tire_pressure": "2.61", + "sensor.ix_xdrive50_rear_right_tire_pressure": "2.69", +} +FIXTURE_DEFAULT_REGION = FIXTURE_CONFIG_ENTRY["data"][CONF_REGION] + @pytest.mark.usefixtures("bmw_fixture") -async def test_update_success(hass: HomeAssistant) -> None: - """Test the reauth form.""" - config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) +async def test_config_entry_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if the coordinator updates the refresh token in config entry.""" + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REFRESH_TOKEN] = "old_token" + config_entry = MockConfigEntry(**config_entry_fixure) config_entry.add_to_hass(hass) + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "old_token" + ) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.runtime_data.last_update_success is True + assert ( + hass.config_entries.async_get_entry(config_entry.entry_id).data[ + CONF_REFRESH_TOKEN + ] + == "another_token_string" + ) @pytest.mark.usefixtures("bmw_fixture") @@ -41,125 +69,176 @@ async def test_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, ) -> None: - """Test the reauth form.""" + """Test a failing API call.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data - - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + # On API error, entities should be unavailable + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAPIError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state @pytest.mark.usefixtures("bmw_fixture") -async def test_update_reauth( +async def test_auth_failed_as_update_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a single auth failure not initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=5, seconds=1)) + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, UpdateFailed) is True + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" - freezer.tick(timedelta(minutes=5, seconds=1)) - with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWAuthError("Test error"), - ): - async_fire_time_changed(hass) - await hass.async_block_till_done() + # And should recover on next update + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + async_fire_time_changed(hass) + await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state + + # Verify that no issues are raised and no reauth flow is initialized + assert len(issue_registry.issues) == 0 + assert len(hass.config_entries.flow.async_progress_by_handler(BMW_DOMAIN)) == 0 @pytest.mark.usefixtures("bmw_fixture") -async def test_init_reauth( +async def test_auth_failed_init_reauth( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" + """Test a two subsequent auth failures initializing reauth flow.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state assert len(issue_registry.issues) == 0 + # Due to flaky API, we allow one retry on AuthError and raise as UpdateFailed + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, side_effect=MyBMWAuthError("Test error"), ): - await hass.config_entries.async_setup(config_entry.entry_id) + async_fire_time_changed(hass) await hass.async_block_till_done() + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 0 + + # On second failure, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) + with patch( + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWAuthError("Test error"), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + reauth_issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id + @pytest.mark.usefixtures("bmw_fixture") async def test_captcha_reauth( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, ) -> None: - """Test the reauth form.""" - TEST_REGION = "north_america" - - config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) - config_entry_fixure["data"][CONF_REGION] = TEST_REGION - config_entry = MockConfigEntry(**config_entry_fixure) + """Test a CaptchaError initializing reauth flow.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - coordinator = config_entry.runtime_data + # Test if entities show data correctly + for entity_id, state in FIXTURE_ENTITY_STATES.items(): + assert hass.states.get(entity_id).state == state - assert coordinator.last_update_success is True - - freezer.tick(timedelta(minutes=10, seconds=1)) + # If library decides a captcha is needed, we should initialize reauth flow + freezer.tick(SCAN_INTERVALS[FIXTURE_DEFAULT_REGION]) with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - side_effect=MyBMWCaptchaMissingError( - "Missing hCaptcha token for North America login" - ), + BIMMER_CONNECTED_VEHICLE_PATCH, + side_effect=MyBMWCaptchaMissingError("Missing hCaptcha token"), ): async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success is False - assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True - assert coordinator.last_exception.translation_key == "missing_captcha" + for entity_id in FIXTURE_ENTITY_STATES: + assert hass.states.get(entity_id).state == "unavailable" + assert len(issue_registry.issues) == 1 + + reauth_issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, + f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", + ) + assert reauth_issue.active is True + + # Check if reauth flow is initialized correctly + flow = hass.config_entries.flow.async_get(reauth_issue.data["flow_id"]) + assert flow["handler"] == BMW_DOMAIN + assert flow["context"]["source"] == "reauth" + assert flow["context"]["unique_id"] == config_entry.unique_id diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index 8507cacc376..d0624825cb5 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import FIXTURE_CONFIG_ENTRY +from . import BIMMER_CONNECTED_VEHICLE_PATCH, FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry @@ -156,7 +156,7 @@ async def test_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -212,7 +212,7 @@ async def test_dont_migrate_unique_ids( assert entity.unique_id == old_unique_id with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", + BIMMER_CONNECTED_VEHICLE_PATCH, return_value=[], ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 53c39f572f2..878edefac27 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -138,13 +138,6 @@ async def test_service_call_invalid_input( HomeAssistantError, REMOTE_SERVICE_EXC_TRANSLATION, ), - ( - ServiceValidationError( - "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit" - ), - ServiceValidationError, - "Option 17 is not valid for entity select.i4_edrive40_ac_charging_limit", - ), ], ) async def test_service_call_fail( diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index d61ed4844a1..73aece4af6b 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -10,12 +10,12 @@ from unittest.mock import MagicMock, Mock, patch from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( patch_bond_bridge, @@ -219,7 +219,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -260,7 +260,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -302,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -349,7 +349,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -393,7 +393,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -437,7 +437,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -475,7 +475,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -522,7 +522,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -561,7 +561,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.3"), ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", @@ -583,7 +583,7 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: await _help_test_form_unexpected_error( hass, source=config_entries.SOURCE_ZEROCONF, - initial_input=zeroconf.ZeroconfServiceInfo( + initial_input=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 63f7169b026..06fd5b9102c 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -13,11 +13,11 @@ from boschshcpy.information import SHCInformation import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.bosch_shc.config_flow import write_tls_asset from homeassistant.components.bosch_shc.const import CONF_SHC_CERT, CONF_SHC_KEY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -25,7 +25,7 @@ MOCK_SETTINGS = { "name": "Test name", "device": {"mac": "test-mac", "hostname": "test-host"}, } -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", @@ -615,7 +615,7 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant) -> None: """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index cd29c647df7..de76c00cd23 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 7a4f93f7f16..497e88053f5 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -10,7 +10,6 @@ from pybravia import ( ) import pytest -from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( CONF_NICKNAME, CONF_USE_PSK, @@ -22,6 +21,12 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import instance_id +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -46,14 +51,14 @@ BRAVIA_SOURCES = [ {"title": "AV/Component", "uri": "extInput:component?port=1"}, ] -BRAVIA_SSDP = ssdp.SsdpServiceInfo( +BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://bravia-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Living TV", - ssdp.ATTR_UPNP_MODEL_NAME: "KE-55XH9096", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Living TV", + ATTR_UPNP_MODEL_NAME: "KE-55XH9096", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": [ @@ -68,14 +73,14 @@ BRAVIA_SSDP = ssdp.SsdpServiceInfo( }, ) -FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( +FAKE_BRAVIA_SSDP = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://soundbar-host:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:1234", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", - ssdp.ATTR_UPNP_MODEL_NAME: "HT-S700RF", + ATTR_UPNP_UDN: "uuid:1234", + ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", + ATTR_UPNP_MODEL_NAME: "HT-S700RF", "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_ServiceList": { "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 62aa38d4e92..da630f7fbc8 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -1,17 +1,23 @@ """Common fixtures for the Bring! tests.""" from collections.abc import Generator -from typing import cast from unittest.mock import AsyncMock, patch import uuid -from bring_api.types import BringAuthResponse +from bring_api import ( + BringActivityResponse, + BringAuthResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, + BringUsersResponse, +) import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture EMAIL = "test-email" PASSWORD = "test-password" @@ -43,12 +49,26 @@ def mock_bring_client() -> Generator[AsyncMock]: ): client = mock_client.return_value client.uuid = UUID - client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) - client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) - client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) - client.get_all_user_settings.return_value = load_json_object_fixture( - "usersettings.json", DOMAIN + client.mail = EMAIL + client.login.return_value = BringAuthResponse.from_json( + load_fixture("login.json", DOMAIN) ) + client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + client.get_list.return_value = BringItemsResponse.from_json( + load_fixture("items.json", DOMAIN) + ) + client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json( + load_fixture("usersettings.json", DOMAIN) + ) + client.get_activity.return_value = BringActivityResponse.from_json( + load_fixture("activity.json", DOMAIN) + ) + client.get_list_users.return_value = BringUsersResponse.from_json( + load_fixture("users.json", DOMAIN) + ) + yield client diff --git a/tests/components/bring/fixtures/activity.json b/tests/components/bring/fixtures/activity.json new file mode 100644 index 00000000000..5e9a8c089d3 --- /dev/null +++ b/tests/components/bring/fixtures/activity.json @@ -0,0 +1,62 @@ +{ + "timeline": [ + { + "type": "LIST_ITEMS_CHANGED", + "content": { + "uuid": "673594a9-f92d-4cb6-adf1-d2f7a83207a4", + "purchase": [ + { + "uuid": "658a3770-1a03-4ee0-94a6-10362a642377", + "itemId": "Gurke", + "specification": "", + "attributes": [] + } + ], + "recently": [ + { + "uuid": "1ed22d3d-f19b-4530-a518-19872da3fd3e", + "itemId": "Milch", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T03:09:33.036Z", + "publicUserUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d" + } + }, + { + "type": "LIST_ITEMS_ADDED", + "content": { + "uuid": "9a16635c-dea2-4e00-904a-c5034f9cfecf", + "items": [ + { + "uuid": "66a633a2-ae09-47bf-8845-3c0198480544", + "itemId": "Joghurt", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T02:54:57.656Z", + "publicUserUuid": "73af455f-c158-4004-a5e0-79f4f8a6d4bd" + } + }, + { + "type": "LIST_ITEMS_REMOVED", + "content": { + "uuid": "303dedf6-d4b2-4d25-a8cd-1c7967b84fcb", + "items": [ + { + "uuid": "2ba8ddb6-01c6-4b0b-a89d-f3da6b291528", + "itemId": "Tofu", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T03:09:12.380Z", + "publicUserUuid": "7d5e9d08-877a-4c36-8740-a9bf74ec690a" + } + } + ], + "timestamp": "2025-01-01T03:09:33.036Z", + "totalEvents": 3 +} diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index e0b9006167b..02bfdc9e038 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,44 +1,46 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "REGISTERED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items2.json b/tests/components/bring/fixtures/items2.json new file mode 100644 index 00000000000..c8f2a5e9d02 --- /dev/null +++ b/tests/components/bring/fixtures/items2.json @@ -0,0 +1,46 @@ +{ + "uuid": "b4776778-7f6c-496e-951b-92a35d3db0dd", + "status": "REGISTERED", + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } +} diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index 82ef623e439..6b6623011da 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,44 +1,46 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "INVITATION", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 9ac999729d3..6892e07e4e6 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,44 +1,46 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "SHARED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/lists2.json b/tests/components/bring/fixtures/lists2.json new file mode 100644 index 00000000000..511de7bd181 --- /dev/null +++ b/tests/components/bring/fixtures/lists2.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "name": "Einkauf", + "theme": "ch.publisheria.bring.theme.home" + } + ] +} diff --git a/tests/components/bring/fixtures/login.json b/tests/components/bring/fixtures/login.json new file mode 100644 index 00000000000..62616471734 --- /dev/null +++ b/tests/components/bring/fixtures/login.json @@ -0,0 +1,12 @@ +{ + "uuid": "4d717571-174a-4bc1-ab24-929c7227ca43", + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "email": "test-email", + "name": "Bring", + "photoPath": "", + "bringListUUID": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "token_type": "Bearer", + "expires_in": 604799 +} diff --git a/tests/components/bring/fixtures/users.json b/tests/components/bring/fixtures/users.json new file mode 100644 index 00000000000..c9393dcb20d --- /dev/null +++ b/tests/components/bring/fixtures/users.json @@ -0,0 +1,31 @@ +{ + "users": [ + { + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "name": "Bring", + "email": "test-email", + "photoPath": "", + "pushEnabled": true, + "plusTryOut": false, + "country": "DE", + "language": "de" + }, + { + "publicUuid": "73af455f-c158-4004-a5e0-79f4f8a6d4bd", + "name": "NAME", + "email": "EMAIL", + "photoPath": "", + "pushEnabled": true, + "plusTryOut": false, + "country": "US", + "language": "en" + }, + { + "publicUuid": "7d5e9d08-877a-4c36-8740-a9bf74ec690a", + "pushEnabled": true, + "plusTryOut": false, + "country": "US", + "language": "en" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 6d830a12133..951c3d3f808 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -1,101 +1,407 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'purchase': list([ - dict({ - 'attributes': list([ + 'data': dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'activity': dict({ + 'timeline': list([ dict({ 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, + 'items': list([ + ]), + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': '2025-01-01T03:09:33.036000+00:00', + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', }), - 'type': 'PURCHASE_CONDITIONS', + 'type': 'LIST_ITEMS_CHANGED', }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ dict({ 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Joghurt', + 'specification': '', + 'uuid': '66a633a2-ae09-47bf-8845-3c0198480544', + }), + ]), + 'publicUserUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T02:54:57.656000+00:00', + 'uuid': '9a16635c-dea2-4e00-904a-c5034f9cfecf', }), - 'type': 'PURCHASE_CONDITIONS', + 'type': 'LIST_ITEMS_ADDED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Tofu', + 'specification': '', + 'uuid': '2ba8ddb6-01c6-4b0b-a89d-f3da6b291528', + }), + ]), + 'publicUserUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T03:09:12.380000+00:00', + 'uuid': '303dedf6-d4b2-4d25-a8cd-1c7967b84fcb', + }), + 'type': 'LIST_ITEMS_REMOVED', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + 'timestamp': '2025-01-01T03:09:33.036000+00:00', + 'totalEvents': 3, }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': 'test-email', + 'language': 'de', + 'name': 'Bring', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': 'EMAIL', + 'language': 'en', + 'name': 'NAME', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ + 'activity': dict({ + 'timeline': list([ + dict({ + 'content': dict({ + 'items': list([ + ]), + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': '2025-01-01T03:09:33.036000+00:00', + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'type': 'LIST_ITEMS_CHANGED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Joghurt', + 'specification': '', + 'uuid': '66a633a2-ae09-47bf-8845-3c0198480544', + }), + ]), + 'publicUserUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T02:54:57.656000+00:00', + 'uuid': '9a16635c-dea2-4e00-904a-c5034f9cfecf', + }), + 'type': 'LIST_ITEMS_ADDED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Tofu', + 'specification': '', + 'uuid': '2ba8ddb6-01c6-4b0b-a89d-f3da6b291528', + }), + ]), + 'publicUserUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T03:09:12.380000+00:00', + 'uuid': '303dedf6-d4b2-4d25-a8cd-1c7967b84fcb', + }), + 'type': 'LIST_ITEMS_REMOVED', + }), + ]), + 'timestamp': '2025-01-01T03:09:33.036000+00:00', + 'totalEvents': 3, + }), + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + }), + 'lst': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': 'test-email', + 'language': 'de', + 'name': 'Bring', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': 'EMAIL', + 'language': 'en', + 'name': 'NAME', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), + ]), + }), + }), }), - 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ - 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': 'Einkauf', - 'purchase': list([ + 'lists': list([ + dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), + dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + ]), + 'user_settings': dict({ + 'userlistsettings': list([ dict({ - 'attributes': list([ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'usersettings': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'key': 'listSectionOrder', + 'value': '["Früchte & Gemüse","Brot & Gebäck","Milch & Käse","Fleisch & Fisch","Zutaten & Gewürze","Fertig- & Tiefkühlprodukte","Getreideprodukte","Snacks & Süsswaren","Getränke & Tabak","Haushalt & Gesundheit","Pflege & Gesundheit","Tierbedarf","Baumarkt & Garten","Eigene Artikel"]', + }), + dict({ + 'key': 'listArticleLanguage', + 'value': 'de-DE', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', }), dict({ - 'attributes': list([ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'usersettings': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'key': 'listSectionOrder', + 'value': '["Früchte & Gemüse","Brot & Gebäck","Milch & Käse","Fleisch & Fisch","Zutaten & Gewürze","Fertig- & Tiefkühlprodukte","Getreideprodukte","Snacks & Süsswaren","Getränke & Tabak","Haushalt & Gesundheit","Pflege & Gesundheit","Tierbedarf","Baumarkt & Garten","Eigene Artikel"]', + }), + dict({ + 'key': 'listArticleLanguage', + 'value': 'en-US', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'recently': list([ + 'usersettings': list([ dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + 'key': 'autoPush', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideOffersBadge', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideSponsoredCategories', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideInspirationsBadge', + 'value': 'ON', + }), + dict({ + 'key': 'onboardClient', + 'value': 'android', + }), + dict({ + 'key': 'premiumHideOffersOnMain', + 'value': 'ON', + }), + dict({ + 'key': 'defaultListUUID', + 'value': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', }), ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', }), }) # --- diff --git a/tests/components/bring/snapshots/test_event.ambr b/tests/components/bring/snapshots/test_event.ambr new file mode 100644 index 00000000000..0bcdcb5b565 --- /dev/null +++ b/tests/components/bring/snapshots/test_event.ambr @@ -0,0 +1,169 @@ +# serializer version: 1 +# name: test_setup[event.baumarkt_activities-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.baumarkt_activities', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activities', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activities', + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_activities', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.baumarkt_activities-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://api.getbring.com/rest/v2/bringusers/profilepictures/9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'event_type': 'list_items_changed', + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + 'friendly_name': 'Baumarkt Activities', + 'items': list([ + ]), + 'last_activity_by': 'Bring', + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': HAFakeDatetime(2025, 1, 1, 3, 9, 33, 36000, tzinfo=datetime.timezone.utc), + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'context': , + 'entity_id': 'event.baumarkt_activities', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T03:30:00.000+00:00', + }) +# --- +# name: test_setup[event.einkauf_activities-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.einkauf_activities', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activities', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activities', + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_activities', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.einkauf_activities-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://api.getbring.com/rest/v2/bringusers/profilepictures/9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'event_type': 'list_items_changed', + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + 'friendly_name': 'Einkauf Activities', + 'items': list([ + ]), + 'last_activity_by': 'Bring', + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': HAFakeDatetime(2025, 1, 1, 3, 9, 33, 36000, tzinfo=datetime.timezone.utc), + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'context': , + 'entity_id': 'event.einkauf_activities', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T03:30:00.000+00:00', + }) +# --- diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr index 97e1d1b4bd9..eb307d31396 100644 --- a/tests/components/bring/snapshots/test_sensor.ambr +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -181,6 +184,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -250,6 +254,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +302,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -350,6 +356,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,6 +409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,6 +480,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -541,6 +550,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bring/snapshots/test_todo.ambr b/tests/components/bring/snapshots/test_todo.ambr index 6a7104727a1..46146415bf6 100644 --- a/tests/components/bring/snapshots/test_todo.ambr +++ b/tests/components/bring/snapshots/test_todo.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 93e86051a75..b9208324c61 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -2,11 +2,7 @@ from unittest.mock import AsyncMock -from bring_api.exceptions import ( - BringAuthException, - BringParseException, - BringRequestException, -) +from bring_api import BringAuthException, BringParseException, BringRequestException import pytest from homeassistant.components.bring.const import DOMAIN @@ -214,3 +210,104 @@ async def test_flow_reauth_unique_id_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_flow_reconfigure( + hass: HomeAssistant, bring_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + bring_config_entry.add_to_hass(hass) + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert bring_config_entry.data[CONF_EMAIL] == "new-email" + assert bring_config_entry.data[CONF_PASSWORD] == "new-password" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (BringRequestException(), "cannot_connect"), + (BringAuthException(), "invalid_auth"), + (BringParseException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + bring_config_entry.add_to_hass(hass) + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_bring_client.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_bring_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert bring_config_entry.data[CONF_EMAIL] == "new-email" + assert bring_config_entry.data[CONF_PASSWORD] == "new-password" + + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test we abort reconfigure if unique id mismatch.""" + + mock_bring_client.uuid = "11111111-11111111-11111111-11111111" + + bring_config_entry.add_to_hass(hass) + + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/bring/test_diagnostics.py b/tests/components/bring/test_diagnostics.py index a86de5a0d2d..c4b8defca82 100644 --- a/tests/components/bring/test_diagnostics.py +++ b/tests/components/bring/test_diagnostics.py @@ -1,11 +1,15 @@ """Test for diagnostics platform of the Bring! integration.""" +from unittest.mock import AsyncMock + +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -16,8 +20,13 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, + mock_bring_client: AsyncMock, ) -> None: """Test diagnostics.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_event.py b/tests/components/bring/test_event.py new file mode 100644 index 00000000000..99b96c27153 --- /dev/null +++ b/tests/components/bring/test_event.py @@ -0,0 +1,46 @@ +"""Test for event platform of the Bring! integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.bring.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_bring_client") +@freeze_time("2025-01-01T03:30:00.000Z") +async def test_setup( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of event platform.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, bring_config_entry.entry_id + ) diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 5ee66999ea4..f053f294ef1 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -1,21 +1,27 @@ """Unit tests for the bring integration.""" +from datetime import timedelta from unittest.mock import AsyncMock -import pytest - -from homeassistant.components.bring import ( +from bring_api import ( BringAuthException, + BringListResponse, BringParseException, BringRequestException, - async_setup_entry, ) +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.bring import async_setup_entry from homeassistant.components.bring.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from .conftest import UUID + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def setup_integration( @@ -114,22 +120,146 @@ async def test_config_entry_not_ready( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) +async def test_config_entry_not_ready_udpdate_failed( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + exception, + ] + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( - "exception", [None, BringAuthException, BringRequestException, BringParseException] + ("exception", "state"), + [ + (BringAuthException, ConfigEntryState.SETUP_ERROR), + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringParseException, ConfigEntryState.SETUP_RETRY), + ], ) async def test_config_entry_not_ready_auth_error( hass: HomeAssistant, bring_config_entry: MockConfigEntry, mock_bring_client: AsyncMock, exception: Exception | None, + state: ConfigEntryState, ) -> None: """Test config entry not ready from authentication error.""" - mock_bring_client.load_lists.side_effect = BringAuthException - mock_bring_client.retrieve_new_access_token.side_effect = exception + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + exception, + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() - assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + assert bring_config_entry.state is state + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_coordinator_skips_deactivated( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the coordinator skips fetching lists for deactivated lists.""" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert mock_bring_client.get_list.await_count == 2 + + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{UUID}_b4776778-7f6c-496e-951b-92a35d3db0dd")} + ) + device_registry.async_update_device(device.id, disabled_by=ConfigEntryDisabler.USER) + + mock_bring_client.get_list.reset_mock() + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_bring_client.get_list.await_count == 1 + + +async def test_purge_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing device entry of deleted list.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + +async def test_create_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test create device entry for new lists.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) diff --git a/tests/components/bring/test_notification.py b/tests/components/bring/test_notification.py index b1fa28335ad..711598d3f4b 100644 --- a/tests/components/bring/test_notification.py +++ b/tests/components/bring/test_notification.py @@ -65,7 +65,7 @@ async def test_send_notification_exception( mock_bring_client.notify.side_effect = BringRequestException with pytest.raises( HomeAssistantError, - match="Failed to send push notification for bring due to a connection error, try again later", + match="Failed to send push notification for Bring! due to a connection error, try again later", ): await hass.services.async_call( DOMAIN, @@ -94,7 +94,7 @@ async def test_send_notification_service_validation_error( with pytest.raises( HomeAssistantError, match=re.escape( - "Failed to perform action bring.send_message. 'URGENT_MESSAGE' requires a value @ data['item']. Got None" + "This action requires field item, please enter a valid value for item" ), ): await hass.services.async_call( diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 974818ccedf..f704debcea9 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -12,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -25,15 +26,19 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_bring_client") async def test_setup( hass: HomeAssistant, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of sensor platform.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() @@ -62,10 +67,9 @@ async def test_list_access_states( ) -> None: """Snapshot test states of list access sensor.""" - mock_bring_client.get_list.return_value = load_json_object_fixture( - f"{fixture}.json", DOMAIN + mock_bring_client.get_list.return_value = BringItemsResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) - bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_todo.py b/tests/components/bring/test_todo.py index 9cc4ae3d888..9df7b892db8 100644 --- a/tests/components/bring/test_todo.py +++ b/tests/components/bring/test_todo.py @@ -4,10 +4,11 @@ from collections.abc import Generator import re from unittest.mock import AsyncMock, patch -from bring_api import BringItemOperation, BringRequestException +from bring_api import BringItemOperation, BringItemsResponse, BringRequestException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_ITEM, @@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -40,9 +41,13 @@ async def test_todo( bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of todo platform.""" - + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 0d9ed0c5345..673c4e68a4d 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,15 +1,19 @@ """Test for utility functions of the Bring! integration.""" -from typing import cast - -from bring_api import BringUserSettingsResponse +from bring_api import ( + BringActivityResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, +) +from bring_api.types import BringUsersResponse import pytest -from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes -from tests.common import load_json_object_fixture +from tests.common import load_fixture @pytest.mark.parametrize( @@ -17,7 +21,7 @@ from tests.common import load_json_object_fixture [ ("e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "de-DE"), ("b4776778-7f6c-496e-951b-92a35d3db0dd", "en-US"), - ("00000000-0000-0000-0000-00000000", None), + ("00000000-0000-0000-0000-000000000000", None), ], ) def test_list_language(list_uuid: str, expected: str | None) -> None: @@ -25,10 +29,7 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: result = list_language( list_uuid, - cast( - BringUserSettingsResponse, - load_json_object_fixture("usersettings.json", DOMAIN), - ), + BringUserSettingsResponse.from_json(load_fixture("usersettings.json", DOMAIN)), ) assert result == expected @@ -44,12 +45,12 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: ) def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" - + items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) + lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) + activity = BringActivityResponse.from_json(load_fixture("activity.json", DOMAIN)) + users = BringUsersResponse.from_json(load_fixture("users.json", DOMAIN)) result = sum_attributes( - cast( - BringData, - load_json_object_fixture("items.json", DOMAIN), - ), + BringData(lst.lists[0], items, activity, users), attribute, ) diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index f31cb380631..14e41bbff19 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -8,10 +8,10 @@ import broadlink.exceptions as blke import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_device @@ -828,7 +828,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress=device.mac, @@ -862,7 +862,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -881,7 +881,7 @@ async def test_dhcp_unreachable(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -900,7 +900,7 @@ async def test_dhcp_connect_unknown_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -922,7 +922,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip=device.host, macaddress=device.mac, @@ -946,7 +946,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", macaddress="34ea34b43b5a", @@ -971,7 +971,7 @@ async def test_dhcp_updates_host(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="broadlink", ip="4.5.6.7", macaddress="34ea34b43b5a", diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py index 2d4eb8e0e0b..7e3ae4efcab 100644 --- a/tests/components/broadlink/test_switch.py +++ b/tests/components/broadlink/test_switch.py @@ -92,7 +92,7 @@ async def test_slots_switch_setup_works( for slot, switch in enumerate(switches): assert ( hass.states.get(switch.entity_id).attributes[ATTR_FRIENDLY_NAME] - == f"{device.name} S{slot+1}" + == f"{device.name} S{slot + 1}" ) assert hass.states.get(switch.entity_id).state == STATE_OFF assert mock_setup.api.auth.call_count == 1 diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index 4de85859461..847ea0a2c6b 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +365,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -408,6 +416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,6 +518,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -608,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +773,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -806,6 +822,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -855,6 +872,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -905,6 +923,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -955,6 +974,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1005,6 +1025,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1055,6 +1076,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1105,6 +1127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1153,6 +1176,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1201,6 +1225,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1251,6 +1276,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1301,6 +1327,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1351,6 +1378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 929e2f083e9..945f5549bbe 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, patch from brother import SnmpError, UnsupportedModelError import pytest -from homeassistant.components import zeroconf from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -121,7 +121,7 @@ async def test_zeroconf_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -145,7 +145,7 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -171,7 +171,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -224,7 +224,7 @@ async def test_zeroconf_confirm_create_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", diff --git a/tests/components/bryant_evolution/snapshots/test_climate.ambr b/tests/components/bryant_evolution/snapshots/test_climate.ambr index 4f6c1f2bbc4..3aeaf66329f 100644 --- a/tests/components/bryant_evolution/snapshots/test_climate.ambr +++ b/tests/components/bryant_evolution/snapshots/test_climate.ambr @@ -21,6 +21,7 @@ 'min_temp': 45, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 16828fea752..70d13f1cb95 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -91,6 +92,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/snapshots/test_sensor.ambr b/tests/components/bsblan/snapshots/test_sensor.ambr index 0146dd23b3d..df7ceecc957 100644 --- a/tests/components/bsblan/snapshots/test_sensor.ambr +++ b/tests/components/bsblan/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr index c1a13b764c0..37fdb14aca9 100644 --- a/tests/components/bsblan/snapshots/test_water_heater.ambr +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 7ee12c5fa1a..41d566fc375 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index c95671a1a6b..ba2af40f319 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index ed920774aa5..173498b14ff 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -20,7 +20,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bthome/test_config_flow.py b/tests/components/bthome/test_config_flow.py index faf2f1c9ef5..5aea3a3cc9b 100644 --- a/tests/components/bthome/test_config_flow.py +++ b/tests/components/bthome/test_config_flow.py @@ -213,6 +213,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "54:48:E6:8F:80:A5" +async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info cache replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="54:48:E6:8F:80:A5", + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.bthome.config_flow.async_discovered_service_info", + return_value=[PRST_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.bthome.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "54:48:E6:8F:80:A5"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "b-parasite 80A5" + assert result2["data"] == {} + assert result2["result"].unique_id == "54:48:E6:8F:80:A5" + + async def test_async_step_user_with_found_devices_encryption( hass: HomeAssistant, ) -> None: diff --git a/tests/components/button/conftest.py b/tests/components/button/conftest.py index 75d5509efc9..0784aa09963 100644 --- a/tests/components/button/conftest.py +++ b/tests/components/button/conftest.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.button import DOMAIN, ButtonEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import TEST_DOMAIN @@ -31,7 +31,7 @@ async def setup_platform(hass: HomeAssistant) -> None: async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up test button platform.""" diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 7df5308e096..783fd786a50 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -175,7 +175,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test button platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index 3e18f595764..5bf061591ee 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -12,7 +12,7 @@ from homeassistant.components.calendar import DOMAIN, CalendarEntity, CalendarEv from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from tests.common import ( @@ -145,7 +145,7 @@ def mock_setup_integration( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" new_entities = create_test_entities() diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 36b102b933a..2d712f408c2 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dfe4622e82e..b0d7944041d 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -25,7 +25,7 @@ from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 64182ee2188..7f4bbed36f7 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.20.218', 'connections': set({ }), @@ -30,4 +31,4 @@ 'sw_version': None, 'via_device_id': None, }) -# --- \ No newline at end of file +# --- diff --git a/tests/components/cambridge_audio/snapshots/test_select.ambr b/tests/components/cambridge_audio/snapshots/test_select.ambr index b40c8a8d5c4..8c9801b101b 100644 --- a/tests/components/cambridge_audio/snapshots/test_select.ambr +++ b/tests/components/cambridge_audio/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/cambridge_audio/snapshots/test_switch.ambr b/tests/components/cambridge_audio/snapshots/test_switch.ambr index 9bfcd7c6da7..cd4326fdcc3 100644 --- a/tests/components/cambridge_audio/snapshots/test_switch.ambr +++ b/tests/components/cambridge_audio/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py index 8d01db6e015..fc184ae2ef5 100644 --- a/tests/components/cambridge_audio/test_config_flow.py +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import AsyncMock from aiostreammagic import StreamMagicError from homeassistant.components.cambridge_audio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 32520fcad23..7fd469fa51a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -41,6 +41,7 @@ from homeassistant.util import dt as dt_util from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( + MockEntityPlatform, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, @@ -300,13 +301,24 @@ async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_camera") -async def test_snapshot_service_os_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + ("target", "side_effect"), + [ + ("homeassistant.components.camera.os.makedirs", OSError), + ( + "homeassistant.components.demo.camera.DemoCamera.async_camera_image", + TimeoutError, + ), + ], +) +async def test_snapshot_service_error( + hass: HomeAssistant, target: str, side_effect: Exception ) -> None: - """Test snapshot service with os error.""" + """Test snapshot service with error.""" with ( patch.object(hass.config, "is_allowed_path", return_value=True), - patch("homeassistant.components.camera.os.makedirs", side_effect=OSError), + patch(target, side_effect=side_effect), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( camera.DOMAIN, @@ -318,8 +330,6 @@ async def test_snapshot_service_os_error( blocking=True, ) - assert "Can't write image to file:" in caplog.text - @pytest.mark.usefixtures("mock_camera", "mock_stream") async def test_websocket_stream_no_source( @@ -826,7 +836,9 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockCamera(camera.Camera): @@ -836,6 +848,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockCamera() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features_compat is camera.CameraEntityFeature(1) assert "MockCamera" in caplog.text assert "is using deprecated supported features values" in caplog.text diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index 27dcbcb3405..a3cda75463f 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -28,6 +28,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -83,6 +84,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +220,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -273,6 +276,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/chacon_dio/snapshots/test_cover.ambr b/tests/components/chacon_dio/snapshots/test_cover.ambr index b2febe20070..afac3359410 100644 --- a/tests/components/chacon_dio/snapshots/test_cover.ambr +++ b/tests/components/chacon_dio/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/chacon_dio/snapshots/test_switch.ambr b/tests/components/chacon_dio/snapshots/test_switch.ambr index 7a65dad5445..a2620005531 100644 --- a/tests/components/chacon_dio/snapshots/test_switch.ambr +++ b/tests/components/chacon_dio/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py index 892d7541354..811978eead5 100644 --- a/tests/components/clicksend_tts/test_notify.py +++ b/tests/components/clicksend_tts/test_notify.py @@ -9,7 +9,7 @@ import pytest import requests_mock from homeassistant.components import notify -import homeassistant.components.clicksend_tts.notify as cs_tts +from homeassistant.components.clicksend_tts import notify as cs_tts from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py index d6aedd23671..8f5834d9180 100644 --- a/tests/components/climate/common.py +++ b/tests/components/climate/common.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -20,10 +21,11 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACMode, ) -from homeassistant.components.climate.const import HVACMode from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -211,6 +213,20 @@ def set_operation_mode( hass.services.call(DOMAIN, SERVICE_SET_HVAC_MODE, data) +async def async_set_swing_horizontal_mode( + hass: HomeAssistant, swing_horizontal_mode: str, entity_id: str = ENTITY_MATCH_ALL +) -> None: + """Set new target swing horizontal mode.""" + data = {ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call( + DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, data, blocking=True + ) + + async def async_set_swing_mode( hass: HomeAssistant, swing_mode: str, entity_id: str = ENTITY_MATCH_ALL ) -> None: diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 45570c63008..8900a9faefa 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -42,7 +42,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -582,7 +582,7 @@ async def test_issue_aux_property_deprecated( async def async_setup_entry_climate_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test weather platform via config entry.""" async_add_entities([climate_entity]) diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index d17f3a1747d..4ce06199eb8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,22 +1,29 @@ """Test climate intents.""" from collections.abc import Generator +from typing import Any import pytest from homeassistant.components import conversation from homeassistant.components.climate import ( + ATTR_TEMPERATURE, DOMAIN, ClimateEntity, + ClimateEntityFeature, HVACMode, intent as climate_intent, ) -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from tests.common import ( @@ -82,7 +89,7 @@ async def create_mock_platform( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) @@ -107,14 +114,29 @@ class MockClimateEntity(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_mode = HVACMode.OFF _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value -async def test_get_temperature( +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_set_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: - """Test HassClimateGetTemperature intent.""" + """Test HassClimateSetTemperature intent.""" assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) @@ -122,6 +144,7 @@ async def test_get_temperature( climate_1._attr_name = "Climate 1" climate_1._attr_unique_id = "1234" climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 entity_registry.async_get_or_create( DOMAIN, "test", "1234", suggested_object_id="climate_1" ) @@ -130,6 +153,7 @@ async def test_get_temperature( climate_2._attr_name = "Climate 2" climate_2._attr_unique_id = "5678" climate_2._attr_current_temperature = 22.0 + climate_2._attr_target_temperature = 22.0 entity_registry.async_get_or_create( DOMAIN, "test", "5678", suggested_object_id="climate_2" ) @@ -149,55 +173,117 @@ async def test_get_temperature( ) entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - # First climate entity will be selected (no area) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, + # Put areas on different floors: + # first floor => living room and office + # upstairs => bedroom + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 - # Select by area (climate_2) + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + + # Cannot target multiple climate devices + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Select by area explicitly (climate_2) response = await intent.async_handle( hass, "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": bedroom_area.name}, "temperature": {"value": 20.1}}, assistant=conversation.DOMAIN, ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(response.matched_states) == 1 assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.1 + + # Select by area implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": bedroom_area.id}, + "temperature": {"value": 20.2}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.2 + + # Select by floor explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"floor": {"value": second_floor.name}, "temperature": {"value": 20.3}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.3 + + # Select by floor implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_floor_id": {"value": second_floor.floor_id}, + "temperature": {"value": 20.4}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.4 # Select by name (climate_2) response = await intent.async_handle( hass, "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 2"}}, + climate_intent.INTENT_SET_TEMPERATURE, + {"name": {"value": "Climate 2"}, "temperature": {"value": 20.5}}, assistant=conversation.DOMAIN, ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(response.matched_states) == 1 assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.5 - # Check area with no climate entities + # Check area with no climate entities (explicit) with pytest.raises(intent.MatchFailedError) as error: response = await intent.async_handle( hass, "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": office_area.name}, "temperature": {"value": 20.6}}, assistant=conversation.DOMAIN, ) @@ -210,45 +296,25 @@ async def test_get_temperature( assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None - # Check wrong name - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( + # Implicit area with no climate entities will fail with multiple targets + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( hass, "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Does not exist"}}, + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": office_area.id}, + "temperature": {"value": 20.7}, + }, + assistant=conversation.DOMAIN, ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME - constraints = error.value.constraints - assert constraints.name == "Does not exist" - assert constraints.area_name is None - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name with area - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name == "Climate 1" - assert constraints.area_name == bedroom_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS -async def test_get_temperature_no_entities( +async def test_set_temperature_no_entities( hass: HomeAssistant, ) -> None: - """Test HassClimateGetTemperature intent with no climate entities.""" + """Test HassClimateSetTemperature intent with no climate entities.""" assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) @@ -258,181 +324,35 @@ async def test_get_temperature_no_entities( await intent.async_handle( hass, "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, assistant=conversation.DOMAIN, ) assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN -async def test_not_exposed( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent when entities aren't exposed.""" +async def test_set_temperature_not_supported(hass: HomeAssistant) -> None: + """Test HassClimateSetTemperature intent when climate entity doesn't support required feature.""" assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) - climate_1 = MockClimateEntity() + climate_1 = MockClimateEntityNoSetTemperature() climate_1._attr_name = "Climate 1" climate_1._attr_unique_id = "1234" climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) + climate_1._attr_target_temperature = 10.0 - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) + await create_mock_platform(hass, [climate_1]) - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to same area - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity( - climate_2.entity_id, area_id=living_room_area.id - ) - - # Should fail with empty name - with pytest.raises(intent.InvalidSlotInfo): + with pytest.raises(intent.MatchFailedError) as error: await intent.async_handle( hass, "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": ""}}, + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20.0}}, assistant=conversation.DOMAIN, ) - # Should fail with empty area - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Expose second, hide first - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the area should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the exposed entity should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_2.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the *unexposed* entity should fail - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_1.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Expose first, hide second - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_1.entity_id - - # Wrong area name - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA - - # Neither are exposed - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with area - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with both names - for name in (climate_1.name, climate_2.name): - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.FEATURE diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 7002f7c39ec..2d594fd9345 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -9,6 +9,7 @@ from hass_nabucasa import Cloud from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.files import Files from hass_nabucasa.google_report_state import GoogleReportState from hass_nabucasa.ice_servers import IceServers from hass_nabucasa.iot import CloudIoT @@ -68,6 +69,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED ) mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None mock_cloud.ice_servers = MagicMock( spec=IceServers, @@ -143,7 +145,12 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: # Methods that we mock with a custom side effect. - async def mock_login(email: str, password: str) -> None: + async def mock_login( + email: str, + password: str, + *, + check_connection: bool = False, + ) -> None: """Mock login. When called, it should call the on_start callback. diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr new file mode 100644 index 00000000000..b15cd08c23a --- /dev/null +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_download_support_package + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + +
mock_no_info_integration + + No information available +
+ +
cloud + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | CertificateStatus.READY + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + +
+ + ## Full logs + +
Logs + + ```logs + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log + 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log + 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log + ``` + +
+ + ''' +# --- diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 3b4868b56ac..ef7a99453f0 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -179,7 +179,7 @@ async def test_alexa_config_invalidate_token( assert await async_setup_component(hass, "homeassistant", {}) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -192,7 +192,7 @@ async def test_alexa_config_invalidate_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -239,7 +239,7 @@ async def test_alexa_config_fail_refresh_token( ) aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", @@ -256,7 +256,7 @@ async def test_alexa_config_fail_refresh_token( "mock-user-id", cloud_prefs, Mock( - alexa_server="example", + servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), ), @@ -286,7 +286,7 @@ async def test_alexa_config_fail_refresh_token( conf.async_invalidate_access_token() aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={"reason": reject_reason}, status=400, ) @@ -312,7 +312,7 @@ async def test_alexa_config_fail_refresh_token( # State reporting should now be re-enabled for Alexa aioclient_mock.clear_requests() aioclient_mock.post( - "https://example/access_token", + "https://example/alexa/access_token", json={ "access_token": "mock-token", "event_endpoint": "http://example.com/alexa_endpoint", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5d9513a1d1b..5220d3eccd5 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -1,14 +1,15 @@ """Test the cloud backup platform.""" -from collections.abc import AsyncGenerator, AsyncIterator, Generator +from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import ANY, Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.files import FilesError, StorageType import pytest -from yarl import URL from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -20,13 +21,23 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -34,7 +45,8 @@ async def setup_integration( cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: - """Set up cloud integration.""" + """Set up cloud and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), @@ -55,49 +67,6 @@ def mock_delete_file() -> Generator[MagicMock]: yield delete_file -@pytest.fixture -def mock_get_download_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_download_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" - ), - } - yield download_details - - -@pytest.fixture -def mock_get_upload_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_upload_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" - ), - "headers": { - "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", - "x-amz-meta-storage-type": "backup", - "x-amz-meta-b64json": ( - "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" - "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" - "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" - "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" - "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" - ), - }, - } - yield download_details - - @pytest.fixture def mock_list_files() -> Generator[MagicMock]: """Mock list files.""" @@ -123,7 +92,26 @@ def mock_list_files() -> Generator[MagicMock]: "size": 34519040, "storage-type": "backup", }, - } + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", + }, + }, ] yield list_files @@ -146,7 +134,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "cloud.cloud", "name": "cloud"}, + ], } @@ -167,19 +158,32 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, - } + }, + { + "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + }, ] @@ -204,6 +208,10 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", } @@ -214,16 +222,15 @@ async def test_agents_list_backups_fail_cloud( "23e64aec", { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, + "extra_metadata": {}, "folders": [], "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, }, @@ -259,52 +266,34 @@ async def test_agents_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get( - mock_get_download_details.return_value["url"], content=b"backup data" - ) + cloud.files.download.return_value = MockStreamReaderChunked(b"backup data") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" - - -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_download_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_get_download_details: Mock, - side_effect: Exception, -) -> None: - """Test agent download backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "23e64aec" - mock_get_download_details.side_effect = side_effect - - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") - assert resp.status == 500 - content = await resp.content.read() - assert "Failed to get download details" in content.decode() + cloud.files.download.assert_called_once_with( + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_fail_get( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup, when cloud user is logged in.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + cloud.files.download.side_effect = FilesError("Oh no :(") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 500 @@ -331,11 +320,11 @@ async def test_agents_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, + cloud: Mock, ) -> None: """Test agent upload backup.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -348,10 +337,8 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) - aioclient_mock.put(mock_get_upload_details.return_value["url"]) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -362,36 +349,41 @@ async def test_agents_upload( ), patch("pathlib.Path.open") as mocked_open, ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][0] == "PUT" - assert aioclient_mock.mock_calls[-1][1] == URL( - mock_get_upload_details.return_value["url"] + cloud.files.upload.assert_called_once_with( + storage_type=StorageType.BACKUP, + open_stream=ANY, + filename=f"{cloud.client.prefs.instance_id}.tar", + base64md5hash=ANY, + metadata=ANY, + size=ANY, ) - assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] + assert metadata["backup_id"] == backup_id assert resp.status == 201 assert f"Uploading backup {backup_id}" in caplog.text -@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.parametrize("side_effect", [FilesError("Boom!"), CloudError("Boom!")]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_upload_fail_put( +async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, - put_mock_kwargs: dict[str, Any], + side_effect: Exception, + cloud: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -404,9 +396,10 @@ async def test_agents_upload_fail_put( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) - aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + cloud.files.upload.side_effect = side_effect with ( patch( @@ -417,16 +410,21 @@ async def test_agents_upload_fail_put( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.asyncio.sleep"), + patch("homeassistant.components.cloud.backup.random.randint", return_value=60), + patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() + assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 2 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] @@ -434,19 +432,33 @@ async def test_agents_upload_fail_put( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in") -async def test_agents_upload_fail_cloud( +@pytest.mark.parametrize( + ("side_effect", "logmsg"), + [ + ( + CloudApiNonRetryableError("Boom!", code="NC-SH-FH-03"), + "The backup size of 13.37GB is too large to be uploaded to Home Assistant Cloud", + ), + ( + CloudApiNonRetryableError("Boom!", code="NC-CE-01"), + "Failed to upload backup Boom!", + ), + ], +) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_non_retryable( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - mock_get_upload_details: Mock, side_effect: Exception, + logmsg: str, + cloud: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test agent upload backup, when cloud user is logged in.""" + """Test agent upload backup fails with non-retryable error.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" - mock_get_upload_details.side_effect = side_effect test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, @@ -458,8 +470,11 @@ async def test_agents_upload_fail_cloud( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=14358124749, ) + + cloud.files.upload.side_effect = side_effect + with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -469,16 +484,19 @@ async def test_agents_upload_fail_cloud( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.calculate_b64md5"), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() + assert logmsg in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 1 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] @@ -493,6 +511,7 @@ async def test_agents_upload_not_protected( ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -505,7 +524,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0, + size=len(backup_data), ) with ( patch("pathlib.Path.open"), @@ -516,7 +535,7 @@ async def test_agents_upload_not_protected( ): resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -528,10 +547,58 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_wrong_size( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + cloud: Mock, +) -> None: + """Test agent upload backup with the wrong size.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data) - 1, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + + assert len(cloud.files.upload.mock_calls) == 0 + + assert resp.status == 201 + assert "Upload failed for cloud.cloud" in caplog.text + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + cloud: Mock, mock_delete_file: Mock, ) -> None: """Test agent delete backup.""" @@ -548,7 +615,11 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_delete_file.assert_called_once() + mock_delete_file.assert_called_once_with( + cloud, + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d915f158af0..81e8554ebf2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,13 +1,17 @@ """Tests for the HTTP API for the cloud component.""" +from collections.abc import Callable, Coroutine from copy import deepcopy +import datetime from http import HTTPStatus import json +import logging from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp -from hass_nabucasa import thingtalk +from freezegun.api import FrozenDateTimeFactory +from hass_nabucasa import AlreadyConnectedError, thingtalk from hass_nabucasa.auth import ( InvalidTotpCode, MFARequired, @@ -15,9 +19,12 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.remote import CertificateStatus from hass_nabucasa.voice import TTS_VOICES import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import system_health from homeassistant.components.alexa import errors as alexa_errors # pylint: disable-next=hass-component-root-import @@ -30,8 +37,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo +from tests.common import mock_platform from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -112,8 +121,8 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", - "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", + "acme_server": "cert-server", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, "alexa": { @@ -364,9 +373,40 @@ async def test_login_view_request_timeout( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} ) + assert cloud.login.call_args[1]["check_connection"] is False + assert req.status == HTTPStatus.BAD_GATEWAY +async def test_login_view_with_already_existing_connection( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test request timeout while trying to log in.""" + cloud_client = await hass_client() + cloud.login.side_effect = AlreadyConnectedError( + details={"remote_ip_address": "127.0.0.1", "connected_at": "1"} + ) + + req = await cloud_client.post( + "/api/cloud/login", + json={ + "email": "my_username", + "password": "my_password", + "check_connection": True, + }, + ) + + assert cloud.login.call_args[1]["check_connection"] is True + assert req.status == HTTPStatus.CONFLICT + resp = await req.json() + assert resp == { + "code": "alreadyconnectederror", + "message": '{"remote_ip_address": "127.0.0.1", "connected_at": "1"}', + } + + async def test_login_view_invalid_credentials( cloud: MagicMock, setup_cloud: None, @@ -1861,3 +1901,112 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.call_count == 1 assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} + + +@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3) +async def test_download_support_package( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test downloading a support package file.""" + + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + now = dt_util.utcnow() + # The logging is done with local time according to the system timezone. Set the + # fake time to 12:00 local time + tz = now.astimezone().tzinfo + freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz)) + logging.getLogger("hass_nabucasa.iot").info( + "This message will be dropped since this test patches MAX_RECORDS" + ) + logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log") + logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log") + logging.getLogger("homeassistant.components.cloud.client").error("Cloud log") + freezer.move_to(now) # Reset time otherwise hass_client auth fails + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index ad123cded84..9a6d4abfc93 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -45,7 +45,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", }, @@ -62,7 +61,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: assert cl.iot.ws_server_url == "wss://test-relayer-server/websocket" assert cl.accounts_server == "test-acounts-server" assert cl.cloudhook_server == "test-cloudhook-server" - assert cl.alexa_server == "test-alexa-server" assert cl.acme_server == "test-acme-server" assert cl.remotestate_server == "test-remotestate-server" diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d165a129dbe..d131d211e2f 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -12,7 +12,7 @@ from homeassistant.components.cloud.repairs import ( ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index bf9fd7302ae..81b10866dff 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -12,7 +12,12 @@ import voluptuous as vol from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_TTS_DEFAULT_VOICE, DOMAIN -from homeassistant.components.cloud.tts import PLATFORM_SCHEMA, SUPPORT_LANGUAGES, Voice +from homeassistant.components.cloud.tts import ( + DEFAULT_VOICES, + PLATFORM_SCHEMA, + SUPPORT_LANGUAGES, + Voice, +) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -61,6 +66,19 @@ def test_default_exists() -> None: assert DEFAULT_TTS_DEFAULT_VOICE[1] in TTS_VOICES[DEFAULT_TTS_DEFAULT_VOICE[0]] +def test_all_languages_have_default() -> None: + """Test all languages have a default voice.""" + assert set(SUPPORT_LANGUAGES).difference(DEFAULT_VOICES) == set() + assert set(DEFAULT_VOICES).difference(SUPPORT_LANGUAGES) == set() + + +@pytest.mark.parametrize(("language", "voice"), DEFAULT_VOICES.items()) +def test_default_voice_is_valid(language: str, voice: str) -> None: + """Test that the default voice is valid.""" + assert language in TTS_VOICES + assert voice in TTS_VOICES[language] + + def test_schema() -> None: """Test schema.""" assert "nl-NL" in SUPPORT_LANGUAGES diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index d629607e503..15a6c5740ff 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.cloudflare.const import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 9218e7343ec..4159c8ec1a1 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr index 3702521e4c3..1e241735102 100644 --- a/tests/components/co2signal/snapshots/test_sensor.ambr +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 51bd946f140..3eab18fb9f3 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 23ba5e7808c..3f920b7dee2 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -25,7 +25,7 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py index 916a684de4b..6475f500f01 100644 --- a/tests/components/comelit/__init__.py +++ b/tests/components/comelit/__init__.py @@ -1 +1,13 @@ """Tests for the Comelit SimpleHome integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py new file mode 100644 index 00000000000..d2d450ccb8d --- /dev/null +++ b/tests/components/comelit/conftest.py @@ -0,0 +1,104 @@ +"""Configure tests for Comelit SimpleHome.""" + +import pytest + +from homeassistant.components.comelit.const import ( + BRIDGE, + DOMAIN as COMELIT_DOMAIN, + VEDO, +) +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE + +from .const import ( + BRIDGE_DEVICE_QUERY, + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + VEDO_DEVICE_QUERY, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) + +from tests.common import AsyncMock, Generator, MockConfigEntry, patch + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.comelit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_serial_bridge() -> Generator[AsyncMock]: + """Mock a Comelit serial bridge.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComeliteSerialBridgeApi", + autospec=True, + ) as mock_comelit_serial_bridge, + patch( + "homeassistant.components.comelit.config_flow.ComeliteSerialBridgeApi", + new=mock_comelit_serial_bridge, + ), + ): + bridge = mock_comelit_serial_bridge.return_value + bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.host = BRIDGE_HOST + bridge.port = BRIDGE_PORT + bridge.pin = BRIDGE_PIN + yield bridge + + +@pytest.fixture +def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit bridge.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + }, + ) + + +@pytest.fixture +def mock_vedo() -> Generator[AsyncMock]: + """Mock a Comelit vedo.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComelitVedoApi", + autospec=True, + ) as mock_comelit_vedo, + patch( + "homeassistant.components.comelit.config_flow.ComelitVedoApi", + new=mock_comelit_vedo, + ), + ): + vedo = mock_comelit_vedo.return_value + vedo.get_all_areas_and_zones.return_value = VEDO_DEVICE_QUERY + vedo.host = VEDO_HOST + vedo.port = VEDO_PORT + vedo.pin = VEDO_PIN + vedo.type = VEDO + yield vedo + + +@pytest.fixture +def mock_vedo_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit vedo.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 92fdfebfa1d..3151b83d175 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,10 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit import ( + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) from aiocomelit.const import ( CLIMATE, COVER, @@ -9,37 +12,20 @@ from aiocomelit.const import ( LIGHT, OTHER, SCENARIO, - VEDO, WATT, AlarmAreaState, AlarmZoneState, ) -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE +BRIDGE_HOST = "fake_bridge_host" +BRIDGE_PORT = 80 +BRIDGE_PIN = 1234 -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PORT: 80, - CONF_PIN: 1234, - }, - { - CONF_HOST: "fake_vedo_host", - CONF_PORT: 8080, - CONF_PIN: 1234, - CONF_TYPE: VEDO, - }, - ] - } -} +VEDO_HOST = "fake_vedo_host" +VEDO_PORT = 8080 +VEDO_PIN = 5678 -MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] - -FAKE_PIN = 5678 +FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { CLIMATE: {}, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 58ce74035f9..b9891eb3209 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -57,9 +57,10 @@ }), 'entry': dict({ 'data': dict({ - 'host': 'fake_host', + 'host': 'fake_bridge_host', 'pin': '**REDACTED**', 'port': 80, + 'type': 'Serial bridge', }), 'disabled_by': None, 'discovery_keys': dict({ @@ -71,6 +72,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -135,6 +138,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index eeaea0e41e9..dd1d1fb3836 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,59 +1,93 @@ """Tests for Comelit SimpleHome config flow.""" -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiocomelit import CannotAuthenticate, CannotConnect +from aiocomelit.const import BRIDGE, VEDO import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA +from .const import ( + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + FAKE_PIN, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) from tests.common import MockConfigEntry -@pytest.mark.parametrize( - ("class_api", "user_input"), - [ - ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), - ("ComelitVedoApi", MOCK_USER_VEDO_DATA), - ], -) -async def test_full_flow( - hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +async def test_flow_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, ) -> None: """Test starting a flow by user.""" - with ( - patch( - f"aiocomelit.api.{class_api}.login", - ), - patch( - f"aiocomelit.api.{class_api}.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == user_input[CONF_HOST] - assert result["data"][CONF_PORT] == user_input[CONF_PORT] - assert result["data"][CONF_PIN] == user_input[CONF_PIN] - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - assert mock_setup_entry.called + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() + + +async def test_flow_vedo( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } + assert not result["result"].unique_id + await hass.async_block_till_done() @pytest.mark.parametrize( @@ -64,7 +98,13 @@ async def test_full_flow( (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect, + error, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -73,59 +113,65 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - side_effect=side_effect, - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch( - "homeassistant.components.comelit.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA - ) + mock_vedo.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == VEDO_HOST + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - patch("requests.get") as mock_request_get, - ): - mock_request_get.return_value.status_code = 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( @@ -136,30 +182,40 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) + mock_vedo.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: VEDO_PIN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_vedo_config_entry.data[CONF_PIN] == VEDO_PIN diff --git a/tests/components/comelit/test_diagnostics.py b/tests/components/comelit/test_diagnostics.py index 39d75af1152..cabcd0f4cac 100644 --- a/tests/components/comelit/test_diagnostics.py +++ b/tests/components/comelit/test_diagnostics.py @@ -2,21 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import ( - BRIDGE_DEVICE_QUERY, - MOCK_USER_BRIDGE_DATA, - MOCK_USER_VEDO_DATA, - VEDO_DEVICE_QUERY, -) +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -25,25 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics_bridge( hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Bridge config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_serial_bridge_config_entry) - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login"), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.get_all_devices", - return_value=BRIDGE_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_serial_bridge_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", @@ -54,25 +39,17 @@ async def test_entry_diagnostics_bridge( async def test_entry_diagnostics_vedo( hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Vedo System config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VEDO_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_vedo_config_entry) - with ( - patch("aiocomelit.api.ComelitVedoApi.login"), - patch( - "aiocomelit.api.ComelitVedoApi.get_all_areas_and_zones", - return_value=VEDO_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_vedo_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 426968eccc5..a6e384fdd6b 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py index 3fbd0e0f898..16a783d4f59 100644 --- a/tests/components/command_line/test_init.py +++ b/tests/components/command_line/test_init.py @@ -11,7 +11,7 @@ from homeassistant import config as hass_config from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index d62410fa792..6b34cf0fa77 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index 03a8272e586..81c696bc6a7 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -7,6 +7,13 @@ import pytest from pytest_unordered import unordered from homeassistant.components.config import area_registry +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar from homeassistant.util.dt import utcnow @@ -24,10 +31,32 @@ async def client_fixture( return await hass_ws_client(hass) +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test list entries.""" created_area1 = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -39,10 +68,12 @@ async def test_list_areas( area2 = area_registry.async_create( "mock 2", aliases={"alias_1", "alias_2"}, - icon="mdi:garage", - picture="/image/example.png", floor_id="first_floor", + humidity_entity_id="sensor.mock_humidity", + icon="mdi:garage", labels={"label_1", "label_2"}, + picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", ) await client.send_json_auto_id({"type": "config/area_registry/list"}) @@ -52,24 +83,28 @@ async def test_list_areas( { "aliases": [], "area_id": area1.id, + "created_at": created_area1.timestamp(), "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], + "modified_at": created_area1.timestamp(), "name": "mock 1", "picture": None, - "created_at": created_area1.timestamp(), - "modified_at": created_area1.timestamp(), + "temperature_entity_id": None, }, { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area2.id, + "created_at": created_area2.timestamp(), "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), + "modified_at": created_area2.timestamp(), "name": "mock 2", "picture": "/image/example.png", - "created_at": created_area2.timestamp(), - "modified_at": created_area2.timestamp(), + "temperature_entity_id": "sensor.mock_temperature", }, ] @@ -78,6 +113,7 @@ async def test_create_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test create entry.""" # Create area with only mandatory parameters @@ -97,6 +133,8 @@ async def test_create_area( "picture": None, "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": None, + "humidity_entity_id": None, } assert len(area_registry.areas) == 1 @@ -109,12 +147,15 @@ async def test_create_area( "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", "type": "config/area_registry/create", } ) msg = await client.receive_json() + assert msg["success"] assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": ANY, @@ -125,6 +166,8 @@ async def test_create_area( "picture": "/image/example.png", "created_at": utcnow().timestamp(), "modified_at": utcnow().timestamp(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } assert len(area_registry.areas) == 2 @@ -185,6 +228,7 @@ async def test_update_area( client: MockHAClientWebSocket, area_registry: ar.AreaRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Test update entry.""" created_at = datetime.fromisoformat("2024-07-16T13:30:00.900075+00:00") @@ -195,14 +239,16 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_2"], "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": ["label_1", "label_2"], "name": "mock 2", "picture": "/image/example.png", - "type": "config/area_registry/update", + "temperature_entity_id": "sensor.mock_temperature", } ) @@ -212,10 +258,12 @@ async def test_update_area( "aliases": unordered(["alias_1", "alias_2"]), "area_id": area.id, "floor_id": "first_floor", + "humidity_entity_id": "sensor.mock_humidity", "icon": "mdi:garage", "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "temperature_entity_id": "sensor.mock_temperature", "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } @@ -226,13 +274,15 @@ async def test_update_area( await client.send_json_auto_id( { + "type": "config/area_registry/update", "aliases": ["alias_1", "alias_1"], "area_id": area.id, "floor_id": None, + "humidity_entity_id": None, "icon": None, "labels": [], "picture": None, - "type": "config/area_registry/update", + "temperature_entity_id": None, } ) @@ -246,6 +296,8 @@ async def test_update_area( "labels": [], "name": "mock 2", "picture": None, + "temperature_entity_id": None, + "humidity_entity_id": None, "created_at": created_at.timestamp(), "modified_at": modified_at.timestamp(), } diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 40a9c85a8d3..b20b0fb5699 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.typing import ClientSessionGenerator @@ -223,7 +223,7 @@ async def test_update_automation_config_with_blueprint_substitution_error( with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): resp = await client.post( "/api/config/automation/config/moon", diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ee000c5ada2..739b79e22bd 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1,8 +1,8 @@ """Test config entries API.""" -from collections import OrderedDict from collections.abc import Generator from http import HTTPStatus +from typing import Any from unittest.mock import ANY, AsyncMock, patch from aiohttp.test_utils import TestClient @@ -12,12 +12,13 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow +from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -137,11 +138,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": True, "supports_reconfigure": False, "supports_remove_device": False, @@ -155,11 +158,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": core_ce.ConfigEntryState.SETUP_ERROR.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -173,11 +178,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -191,11 +198,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -209,11 +218,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -389,19 +400,17 @@ async def test_available_flows( ############################ -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can initialize a flow.""" mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", @@ -481,13 +490,14 @@ async def test_initialize_flow_unauth( class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema(schema), description_placeholders={"url": "https://example.com"}, errors={"username": "Should be unique."}, ) @@ -500,10 +510,7 @@ async def test_initialize_flow_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.bla"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_abort(hass: HomeAssistant, client: TestClient) -> None: """Test a flow that aborts.""" mock_platform(hass, "test.config_flow", None) @@ -528,7 +535,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: } -@pytest.mark.usefixtures("enable_custom_integrations", "freezer") +@pytest.mark.usefixtures("freezer") async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: """Test a flow that creates an account.""" mock_platform(hass, "test.config_flow", None) @@ -571,11 +578,13 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": core_ce.SOURCE_USER, "state": core_ce.ConfigEntryState.LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -586,10 +595,11 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": [], } -@pytest.mark.usefixtures("enable_custom_integrations", "freezer") +@pytest.mark.usefixtures("freezer") async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can finish a two step flow.""" mock_integration( @@ -654,11 +664,13 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": core_ce.SOURCE_USER, "state": core_ce.ConfigEntryState.LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -669,6 +681,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": [], } @@ -729,27 +742,62 @@ async def test_get_progress_index( mock_platform(hass, "test.config_flow", None) ws_client = await hass_ws_client(hass) + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + + entry = MockConfigEntry(domain="test", title="Test", entry_id="1234") + entry.add_to_hass(hass) + class TestFlow(core_ce.ConfigFlow): VERSION = 5 - async def async_step_hassio(self, discovery_info): + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle a Hass.io discovery.""" return await self.async_step_account() - async def async_step_account(self, user_input=None): + async def async_step_account(self, user_input: dict[str, Any] | None = None): + """Show a form to the user.""" return self.async_show_form(step_id="account") + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle a config flow initialized by the user.""" + return await self.async_step_account() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ): + """Handle a reconfiguration flow initialized by the user.""" + nonlocal entry + assert self._get_reconfigure_entry() is entry + return await self.async_step_account() + with patch.dict(HANDLERS, {"test": TestFlow}): - form = await hass.config_entries.flow.async_init( + form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) + form_user = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_USER} + ) + form_reconfigure = await hass.config_entries.flow.async_init( + "test", context={"source": core_ce.SOURCE_RECONFIGURE, "entry_id": "1234"} + ) + + for form in (form_hassio, form_user, form_reconfigure): + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["step_id"] == "account" await ws_client.send_json({"id": 5, "type": "config_entries/flow/progress"}) response = await ws_client.receive_json() assert response["success"] + + # Active flows with SOURCE_USER and SOURCE_RECONFIGURE should be filtered out assert response["result"] == [ { - "flow_id": form["flow_id"], + "flow_id": form_hassio["flow_id"], "handler": "test", "step_id": "account", "context": {"source": core_ce.SOURCE_HASSIO}, @@ -772,19 +820,17 @@ async def test_get_progress_index_unauth( assert response["error"]["code"] == "unauthorized" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can query the API for same result as we get from init a flow.""" mock_platform(hass, "test.config_flow", None) class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", @@ -808,10 +854,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non assert data == data2 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow_unauth( hass: HomeAssistant, client: TestClient, hass_admin_user: MockUser ) -> None: @@ -820,9 +863,10 @@ async def test_get_progress_flow_unauth( class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", @@ -854,11 +898,9 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: def async_get_options_flow(config_entry): class OptionsFlowHandler(data_entry_flow.FlowHandler): async def async_step_init(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=vol.Schema(schema), + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -919,11 +961,9 @@ async def test_options_flow_unauth( def async_get_options_flow(config_entry): class OptionsFlowHandler(data_entry_flow.FlowHandler): async def async_step_init(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -1088,6 +1128,409 @@ async def test_options_flow_with_invalid_data( assert data == {"errors": {"choices": "invalid is not a valid option"}} +async def test_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can start a subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required("enabled"): bool}), + description_placeholders={"enabled": "Set to true to be true"}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "user", + "data_schema": [{"name": "enabled", "required": True, "type": "boolean"}], + "description_placeholders": {"enabled": "Set to true to be true"}, + "errors": None, + "last_step": None, + "preview": None, + } + + +async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: + """Test we can start and finish a subentry reconfigure flow.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + raise NotImplementedError + + async def async_step_reconfigure(self, user_input=None): + if user_input is not None: + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + title="Test Entry", + data={"test": "blah"}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required("enabled"): bool}), + description_placeholders={"enabled": "Set to true to be true"}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id=None, + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post( + url, json={"handler": [entry.entry_id, "test"], "subentry_id": "mock_id"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "reconfigure", + "data_schema": [{"name": "enabled", "required": True, "type": "boolean"}], + "description_placeholders": {"enabled": "Set to true to be true"}, + "errors": None, + "last_step": None, + "preview": None, + } + + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + + entries = hass.config_entries.async_entries("test") + assert len(entries) == 1 + + data = await resp.json() + data.pop("flow_id") + assert data == { + "handler": ["test1", "test"], + "reason": "reconfigure_successful", + "type": "abort", + "description_placeholders": None, + } + + entry = hass.config_entries.async_entries()[0] + assert entry.subentries == { + "mock_id": core_ce.ConfigSubentry( + data={"test": "blah"}, + subentry_id="mock_id", + subentry_type="test", + title="Test Entry", + unique_id=None, + ), + } + + +async def test_subentry_does_not_support_reconfigure( + hass: HomeAssistant, client: TestClient +) -> None: + """Test a subentry flow that does not support reconfigure step.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + raise NotImplementedError + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id=None, + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post( + url, json={"handler": [entry.entry_id, "test"], "subentry_id": "mock_id"} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + response = await resp.json() + assert response == { + "message": "Handler SubentryFlowHandler doesn't support step reconfigure" + } + + +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/api/config/config_entries/subentries/flow", "post"), + ("/api/config/config_entries/subentries/flow/1", "get"), + ("/api/config/config_entries/subentries/flow/1", "post"), + ], +) +async def test_subentry_flow_unauth( + hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str +) -> None: + """Test unauthorized on subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required("enabled"): bool}), + description_placeholders={"enabled": "Set to true to be true"}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + hass_admin_user.groups = [] + + with mock_config_flow("test", TestFlow): + resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.UNAUTHORIZED + + +async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can finish a two step subentry flow.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return await self.async_step_finish() + + async def async_step_finish(self, user_input=None): + if user_input: + return self.async_create_entry( + title="Mock title", data=user_input, unique_id="test" + ) + + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data["flow_id"] + expected_data = { + "data_schema": [{"name": "enabled", "type": "boolean"}], + "description_placeholders": None, + "errors": None, + "flow_id": flow_id, + "handler": ["test1", "test"], + "last_step": None, + "preview": None, + "step_id": "finish", + "type": "form", + } + assert data == expected_data + + resp = await client.get(f"/api/config/config_entries/subentries/flow/{flow_id}") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == expected_data + + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "description_placeholders": None, + "description": None, + "flow_id": flow_id, + "handler": ["test1", "test"], + "title": "Mock title", + "type": "create_entry", + "unique_id": "test", + } + + +async def test_subentry_flow_with_invalid_data(hass: HomeAssistant, client) -> None: + """Test a subentry flow with invalid_data.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="finish", + data_schema=vol.Schema( + { + vol.Required( + "choices", default=["invalid", "valid"] + ): cv.multi_select({"valid": "Valid"}) + } + ), + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title="Enable disable", data=user_input) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "finish", + "data_schema": [ + { + "default": ["invalid", "valid"], + "name": "choices", + "options": {"valid": "Valid"}, + "required": True, + "type": "multi_select", + } + ], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"choices": ["valid", "invalid"]}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == {"errors": {"choices": "invalid is not a valid option"}} + + @pytest.mark.usefixtures("freezer") async def test_get_single( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1120,11 +1563,13 @@ async def test_get_single( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "user", "state": "loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1480,11 +1925,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1499,11 +1946,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1518,11 +1967,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1537,11 +1988,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1556,11 +2009,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1586,11 +2041,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1615,11 +2072,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1634,11 +2093,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1663,11 +2124,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1682,11 +2145,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1717,11 +2182,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1736,11 +2203,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1755,11 +2224,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1774,11 +2245,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1793,11 +2266,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1900,11 +2375,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1922,11 +2399,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1944,11 +2423,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1972,11 +2453,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2001,11 +2484,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2029,11 +2514,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": entry.modified_at.timestamp(), + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2119,11 +2606,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2141,11 +2630,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2171,11 +2662,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2197,11 +2690,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2227,11 +2722,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2255,11 +2752,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": entry.modified_at.timestamp(), + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2359,11 +2858,8 @@ async def test_flow_with_multiple_schema_errors_base( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.reconfigure_successful"], -) -@pytest.mark.usefixtures("enable_custom_integrations", "freezer") +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) +@pytest.mark.usefixtures("freezer") async def test_supports_reconfigure( hass: HomeAssistant, client: TestClient, @@ -2439,7 +2935,6 @@ async def test_supports_reconfigure( } -@pytest.mark.usefixtures("enable_custom_integrations") async def test_does_not_support_reconfigure( hass: HomeAssistant, client: TestClient ) -> None: @@ -2465,8 +2960,144 @@ async def test_does_not_support_reconfigure( ) assert resp.status == HTTPStatus.BAD_REQUEST - response = await resp.text() - assert ( - response - == '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}' + response = await resp.json() + assert response == {"message": "Handler TestFlow doesn't support step reconfigure"} + + +async def test_list_subentries( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can list subentries.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries_data=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, + subentry_id="mock_id", + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == [ + { + "subentry_id": "mock_id", + "subentry_type": "test", + "title": "Mock title", + "unique_id": "test", + }, + ] + + # Try listing subentries for an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": "no_such_entry", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + + +async def test_delete_subentry( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can delete a subentry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries_data=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, + subentry_id="mock_id", + subentry_type="test", + title="Mock title", + ) + ], + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] is None + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == [] + + # Try deleting the subentry again + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config subentry not found", + } + + # Try deleting subentry from an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": "no_such_entry", + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 4550f2e08e5..ee133d3dddd 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -10,7 +10,7 @@ from homeassistant.components.config import core from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, location +from homeassistant.util import dt as dt_util, location as location_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import MockUser @@ -238,7 +238,7 @@ async def test_detect_config_fail(hass: HomeAssistant, client) -> None: """Test detect config.""" with patch( "homeassistant.util.location.async_detect_location_info", - return_value=location.LocationInfo( + return_value=location_util.LocationInfo( ip=None, country_code=None, currency=None, diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index c840ce2bed2..8a4e1ef234f 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -65,6 +65,7 @@ async def test_list_devices( { "area_id": None, "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -87,6 +88,7 @@ async def test_list_devices( { "area_id": None, "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, "configuration_url": None, "connections": [], "created_at": utcnow().timestamp(), @@ -121,6 +123,7 @@ async def test_list_devices( { "area_id": None, "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index bfbd69ec9bd..2e3de33d808 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -67,6 +67,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, @@ -89,6 +90,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, @@ -138,6 +140,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, @@ -374,6 +377,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -410,6 +414,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": no_name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -477,6 +482,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -504,6 +510,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": no_name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -586,6 +593,7 @@ async def test_update_entity( "categories": {"scope1": "id", "scope2": "id"}, "created_at": created.timestamp(), "config_entry_id": None, + "config_subentry_id": None, "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -668,6 +676,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -714,6 +723,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -759,6 +769,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id", "scope3": "id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -804,6 +815,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id", "scope3": "other_id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -849,6 +861,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope3": "other_id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -911,6 +924,7 @@ async def test_update_entity_require_restart( "capabilities": None, "categories": {}, "config_entry_id": config_entry.entry_id, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": None, "device_id": None, @@ -1032,6 +1046,7 @@ async def test_update_entity_no_changes( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": None, "device_id": None, @@ -1129,6 +1144,7 @@ async def test_update_entity_id( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": None, "device_id": None, diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 88245eb567f..10d453b17f1 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.typing import ClientSessionGenerator @@ -226,7 +226,7 @@ async def test_update_script_config_with_blueprint_substitution_error( with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): resp = await client.post( "/api/config/script/config/moon", diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index 6c937473ddc..1985c6e5c8c 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import configurator from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -14,9 +14,9 @@ async def test_request_least_info(hass: HomeAssistant) -> None: """Test request config with least amount of data.""" request_id = configurator.async_request_config(hass, "Test Request", lambda _: None) - assert ( - len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1 - ), "No new service registered" + assert len(hass.services.async_services().get(configurator.DOMAIN, [])) == 1, ( + "No new service registered" + ) states = hass.states.async_all() diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 81f7b2044d6..6d6d0d4641f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, Callable, Generator from functools import lru_cache from importlib.util import find_spec from pathlib import Path +import re import string from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -21,6 +22,7 @@ from aiohasupervisor.models import ( import pytest import voluptuous as vol +from homeassistant import components, loader from homeassistant.components import repairs from homeassistant.config_entries import ( DISCOVERY_SOURCES, @@ -40,7 +42,9 @@ from homeassistant.data_entry_flow import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.translation import async_get_translations -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util + +from tests.common import QualityScaleStatus, get_quality_scale if TYPE_CHECKING: from homeassistant.components.hassio import AddonManager @@ -51,6 +55,9 @@ if TYPE_CHECKING: from .sensor.common import MockSensor from .switch.common import MockSwitch +# Regex for accessing the integration name from the test path +RE_REQUEST_DOMAIN = re.compile(r".*tests\/components\/([^/]+)\/.*") + @pytest.fixture(scope="session", autouse=find_spec("zeroconf") is not None) def patch_zeroconf_multiple_catcher() -> Generator[None]: @@ -522,13 +529,15 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" - mounts_info_mock = AsyncMock(spec_set=["mounts"]) + mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"]) + mounts_info_mock.default_backup_mount = None mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() @@ -566,6 +575,10 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, ), + patch( + "homeassistant.components.hassio.update_helper.get_supervisor_client", + return_value=supervisor_client, + ), ): yield supervisor_client @@ -593,6 +606,7 @@ def _validate_translation_placeholders( async def _validate_translation( hass: HomeAssistant, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], category: str, component: str, key: str, @@ -602,7 +616,25 @@ async def _validate_translation( ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" + if component in ignore_translations_for_mock_domains: + try: + integration = await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + return + component_paths = components.__path__ + if not any( + Path(f"{component_path}/{component}") == integration.file_path + for component_path in component_paths + ): + return + # If the integration exists, translation errors should be ignored via the + # ignore_missing_translations fixture instead of the + # ignore_translations_for_mock_domains fixture. + translation_errors[full_key] = f"The integration '{component}' exists" + return + translations = await async_get_translations(hass, "en", category, [component]) + if (translation := translations.get(full_key)) is not None: _validate_translation_placeholders( full_key, translation, description_placeholders, translation_errors @@ -612,7 +644,20 @@ async def _validate_translation( if not translation_required: return - if full_key in translation_errors: + if translation_errors.get(full_key) in {"used", "unused"}: + # If the does not integration exist, translation errors should be ignored + # via the ignore_translations_for_mock_domains fixture instead of the + # ignore_missing_translations fixture. + try: + await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + translation_errors[full_key] = ( + f"Translation not found for {component}: `{category}.{key}`. " + f"The integration '{component}' does not exist." + ) + return + + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -623,11 +668,22 @@ async def _validate_translation( @pytest.fixture -def ignore_translations() -> str | list[str]: - """Ignore specific translations. +def ignore_missing_translations() -> str | list[str]: + """Ignore specific missing translations. - Override or parametrize this fixture with a fixture that returns, - a list of translation that should be ignored. + Override or parametrize this fixture with a fixture that returns + a list of missing translation that should be ignored. + """ + return [] + + +@pytest.fixture +def ignore_translations_for_mock_domains() -> str | list[str]: + """Don't validate translations for specific domains. + + Override or parametrize this fixture with a fixture that returns + a list of domains for which translations should not be validated. + This should only be used when testing mocked integrations. """ return [] @@ -636,7 +692,7 @@ def ignore_translations() -> str | list[str]: def _get_integration_quality_scale(integration: str) -> dict[str, Any]: """Get the quality scale for an integration.""" try: - return yaml.load_yaml_dict( + return yaml_util.load_yaml_dict( f"homeassistant/components/{integration}/quality_scale.yaml" ).get("rules", {}) except FileNotFoundError: @@ -660,6 +716,7 @@ async def _check_step_or_section_translations( translation_prefix: str, description_placeholders: dict[str, str], data_schema: vol.Schema | None, + ignore_translations_for_mock_domains: set[str], ) -> None: # neither title nor description are required # - title defaults to integration name @@ -668,6 +725,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}", @@ -689,6 +747,7 @@ async def _check_step_or_section_translations( f"{translation_prefix}.sections.{data_key}", description_placeholders, data_value.schema, + ignore_translations_for_mock_domains, ) continue iqs_config_flow = _get_integration_quality_scale_rule( @@ -699,6 +758,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}.{data_key}", @@ -712,6 +772,7 @@ async def _check_config_flow_result_translations( flow: FlowHandler, result: FlowResult[FlowContext, str], translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if result["type"] is FlowResultType.CREATE_ENTRY: # No need to check translations for a completed flow @@ -747,6 +808,7 @@ async def _check_config_flow_result_translations( f"{key_prefix}step.{step_id}", result["description_placeholders"], result["data_schema"], + ignore_translations_for_mock_domains, ) if errors := result.get("errors"): @@ -754,6 +816,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}error.{error}", @@ -769,9 +832,10 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, - f"{key_prefix}abort.{result["reason"]}", + f"{key_prefix}abort.{result['reason']}", result["description_placeholders"], ) @@ -780,6 +844,7 @@ async def _check_create_issue_translations( issue_registry: ir.IssueRegistry, issue: ir.IssueEntry, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if issue.translation_key is None: # `translation_key` is only None on dismissed issues @@ -787,6 +852,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.title", @@ -797,6 +863,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.description", @@ -804,16 +871,35 @@ async def _check_create_issue_translations( ) +def _get_request_quality_scale( + request: pytest.FixtureRequest, rule: str +) -> QualityScaleStatus: + if not (match := RE_REQUEST_DOMAIN.match(str(request.path))): + return QualityScaleStatus.TODO + integration = match.groups(1)[0] + return get_quality_scale(integration).get(rule, QualityScaleStatus.TODO) + + async def _check_exception_translation( hass: HomeAssistant, exception: HomeAssistantError, translation_errors: dict[str, str], + request: pytest.FixtureRequest, + ignore_translations_for_mock_domains: set[str], ) -> None: if exception.translation_key is None: + if ( + _get_request_quality_scale(request, "exception-translations") + is QualityScaleStatus.DONE + ): + translation_errors["quality_scale"] = ( + f"Found untranslated {type(exception).__name__} exception: {exception}" + ) return await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, "exceptions", exception.translation_domain, f"{exception.translation_key}.message", @@ -823,18 +909,27 @@ async def _check_exception_translation( @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], + ignore_missing_translations: str | list[str], + ignore_translations_for_mock_domains: str | list[str], + request: pytest.FixtureRequest, ) -> AsyncGenerator[None]: """Check that translation requirements are met. Current checks: - data entry flow results (ConfigFlow/OptionsFlow/RepairFlow) - issue registry entries + - action (service) exceptions """ - if not isinstance(ignore_translations, list): - ignore_translations = [ignore_translations] + if not isinstance(ignore_missing_translations, list): + ignore_missing_translations = [ignore_missing_translations] - translation_errors = {k: "unused" for k in ignore_translations} + if not isinstance(ignore_translations_for_mock_domains, list): + ignored_domains = {ignore_translations_for_mock_domains} + else: + ignored_domains = set(ignore_translations_for_mock_domains) + + # Set all ignored translation keys to "unused" + translation_errors = {k: "unused" for k in ignore_missing_translations} translation_coros = set() @@ -849,7 +944,7 @@ async def check_translations( ) -> FlowResult: result = await _original_flow_manager_async_handle_step(self, flow, *args) await _check_config_flow_result_translations( - self, flow, result, translation_errors + self, flow, result, translation_errors, ignored_domains ) return result @@ -860,7 +955,9 @@ async def check_translations( self, domain, issue_id, *args, **kwargs ) translation_coros.add( - _check_create_issue_translations(self, result, translation_errors) + _check_create_issue_translations( + self, result, translation_errors, ignored_domains + ) ) return result @@ -887,7 +984,13 @@ async def check_translations( ) except HomeAssistantError as err: translation_coros.add( - _check_exception_translation(self._hass, err, translation_errors) + _check_exception_translation( + self._hass, + err, + translation_errors, + request, + ignored_domains, + ) ) raise @@ -913,10 +1016,11 @@ async def check_translations( # Run final checks unused_ignore = [k for k, v in translation_errors.items() if v == "unused"] if unused_ignore: + # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " - "Please remove them from the ignore_translations fixture." + "Please remove them from the ignore_missing_translations fixture." ) for description in translation_errors.values(): - if description not in {"used", "unused"}: + if description != "used": pytest.fail(description) diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 1ae3372968e..eeab8b6b9af 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -2,7 +2,12 @@ from __future__ import annotations +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field from typing import Literal +from unittest.mock import patch + +import pytest from homeassistant.components import conversation from homeassistant.components.conversation.models import ( @@ -14,7 +19,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent class MockAgent(conversation.AbstractConversationAgent): @@ -44,6 +49,53 @@ class MockAgent(conversation.AbstractConversationAgent): ) +@pytest.fixture +async def mock_chat_log(hass: HomeAssistant) -> AsyncGenerator[MockChatLog]: + """Return mock chat logs.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch( + "homeassistant.components.conversation.chat_log.ChatLog", + MockChatLog, + ), + chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + yield chat_log + + +@dataclass +class MockChatLog(conversation.ChatLog): + """Mock chat log.""" + + _mock_tool_results: dict = field(default_factory=dict) + + def mock_tool_results(self, results: dict) -> None: + """Set tool results.""" + self._mock_tool_results = results + + @property + def llm_api(self): + """Return LLM API.""" + return self._llm_api + + @llm_api.setter + def llm_api(self, value): + """Set LLM API.""" + self._llm_api = value + + if not value: + return + + async def async_call_tool(tool_input): + """Call tool.""" + if tool_input.id not in self._mock_tool_results: + raise ValueError(f"Tool {tool_input.id} not found") + return self._mock_tool_results[tool_input.id] + + self._llm_api.async_call_tool = async_call_tool + + def expose_new(hass: HomeAssistant, expose_new: bool) -> None: """Enable exposing new entities to the default agent.""" exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr new file mode 100644 index 00000000000..ff8ebf724cd --- /dev/null +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_add_delta_content_stream[deltas0] + list([ + ]) +# --- +# name: test_add_delta_content_stream[deltas1] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas2] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test 2', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas3] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas4] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas5] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test 2', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas6] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + dict({ + 'id': 'mock-tool-call-id-2', + 'tool_args': dict({ + 'param1': 'Test Param 2', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id-2', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 2', + }), + ]) +# --- +# name: test_template_error + dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I had a problem with my template', + }), + }), + }), + }) +# --- +# name: test_unknown_llm_api + dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index f1e220b10b2..02e4ef1befe 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,7 +1,8 @@ # serializer version: 1 # name: test_custom_sentences dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -26,7 +27,8 @@ # --- # name: test_custom_sentences.1 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -51,7 +53,8 @@ # --- # name: test_custom_sentences_config dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -76,7 +79,8 @@ # --- # name: test_intent_alias_added_removed dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -106,7 +110,8 @@ # --- # name: test_intent_alias_added_removed.1 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -136,7 +141,8 @@ # --- # name: test_intent_alias_added_removed.2 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -156,7 +162,8 @@ # --- # name: test_intent_conversion_not_expose_new dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -176,7 +183,8 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -206,7 +214,8 @@ # --- # name: test_intent_entity_added_removed dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -236,7 +245,8 @@ # --- # name: test_intent_entity_added_removed.1 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -266,7 +276,8 @@ # --- # name: test_intent_entity_added_removed.2 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -296,7 +307,8 @@ # --- # name: test_intent_entity_added_removed.3 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -316,7 +328,8 @@ # --- # name: test_intent_entity_exposed dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -346,7 +359,8 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -366,7 +380,8 @@ # --- # name: test_intent_entity_remove_custom_name dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -386,7 +401,8 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -416,7 +432,8 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -436,7 +453,8 @@ # --- # name: test_intent_entity_renamed dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -466,7 +484,8 @@ # --- # name: test_intent_entity_renamed.1 dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 0de575790db..849a5b17102 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -49,6 +49,7 @@ 'sk', 'sl', 'sr', + 'sr-Latn', 'sv', 'sw', 'te', @@ -201,7 +202,8 @@ # --- # name: test_http_api_handle_failure dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -221,7 +223,8 @@ # --- # name: test_http_api_no_match dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -241,7 +244,8 @@ # --- # name: test_http_api_unexpected_failure dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -261,7 +265,8 @@ # --- # name: test_http_processing_intent[None] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -291,7 +296,8 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -321,7 +327,8 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -351,7 +358,8 @@ # --- # name: test_ws_api[payload0] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -371,7 +379,8 @@ # --- # name: test_ws_api[payload1] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -391,7 +400,8 @@ # --- # name: test_ws_api[payload2] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -411,7 +421,8 @@ # --- # name: test_ws_api[payload3] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -431,7 +442,8 @@ # --- # name: test_ws_api[payload4] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -451,7 +463,8 @@ # --- # name: test_ws_api[payload5] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -539,7 +552,7 @@ 'name': 'HassTurnOn', }), 'match': True, - 'sentence_template': ' on [] ', + 'sentence_template': ' on [(|)] ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', @@ -638,7 +651,7 @@ 'brightness': dict({ 'name': 'brightness', 'text': '100', - 'value': 100, + 'value': 100.0, }), 'name': dict({ 'name': 'name', @@ -690,7 +703,7 @@ 'targets': dict({ }), 'unmatched_slots': dict({ - 'brightness': 1001, + 'brightness': 1001.0, }), }), ]), diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 0327be064d4..3d843d4e32a 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,7 +1,8 @@ # serializer version: 1 # name: test_custom_agent dict({ - 'conversation_id': 'test-conv-id', + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -44,7 +45,8 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -74,7 +76,8 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -104,7 +107,8 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -134,7 +138,8 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -164,7 +169,8 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -194,7 +200,8 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -224,7 +231,8 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -254,7 +262,8 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -284,7 +293,8 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -314,7 +324,8 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -344,7 +355,8 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), @@ -374,7 +386,8 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': , 'response': dict({ 'card': dict({ }), diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py new file mode 100644 index 00000000000..a4dc9b819c1 --- /dev/null +++ b/tests/components/conversation/test_chat_log.py @@ -0,0 +1,643 @@ +"""Test the conversation session.""" + +from collections.abc import Generator +from dataclasses import asdict +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.conversation import ( + AssistantContent, + ConversationInput, + ConverseError, + ToolResultContent, + async_get_chat_log, +) +from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import chat_session, llm +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: + """Return a conversation input instance.""" + return ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +async def test_cleanup( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test cleanup of the chat log.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + conversation_id = session.conversation_id + # Add message so it persists + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert conversation_id in hass.data[DATA_CHAT_LOGS] + + # Set the last updated to be older than the timeout + hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = ( + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + ) + + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1), + ) + + assert conversation_id not in hass.data[DATA_CHAT_LOGS] + + +async def test_default_content( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test filtering of messages.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + assert len(chat_log.content) == 2 + assert chat_log.content[0].role == "system" + assert chat_log.content[0].content == "" + assert chat_log.content[1].role == "user" + assert chat_log.content[1].content == mock_conversation_input.text + + +async def test_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + assert isinstance(chat_log.llm_api, llm.APIInstance) + assert chat_log.llm_api.api.id == "assist" + + +async def test_unknown_llm_api( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an LLM API that does not exists.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + pytest.raises(ConverseError) as exc_info, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="unknown-api", + user_llm_prompt=None, + ) + + assert str(exc_info.value) == "Error getting LLM API unknown-api" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_error( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, +) -> None: + """Test that template error handling works.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + pytest.raises(ConverseError) as exc_info, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt="{{ invalid_syntax", + ) + + assert str(exc_info.value) == "Error rendering prompt" + assert exc_info.value.as_conversation_result().as_dict() == snapshot + + +async def test_template_variables( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that template variables work.""" + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + mock_conversation_input.context = Context(user_id=mock_user.id) + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=( + "The instance name is {{ ha_name }}. " + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + "The calling platform is {{ llm_context.platform }}." + ), + ) + + assert "The instance name is test home." in chat_log.content[0].content + assert "The user name is Test User." in chat_log.content[0].content + assert "The user id is 12345." in chat_log.content[0].content + assert "The calling platform is test." in chat_log.content[0].content + + +async def test_extra_systen_prompt( + hass: HomeAssistant, mock_conversation_input: ConversationInput +) -> None: + """Test that extra system prompt works.""" + extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." + extra_system_prompt2 = ( + "User person.paulus came home. Asked him what he wants to do." + ) + mock_conversation_input.extra_system_prompt = extra_system_prompt + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_log.extra_system_prompt == extra_system_prompt + assert chat_log.content[0].content.endswith(extra_system_prompt) + + # Verify that follow-up conversations with no system prompt take previous one + conversation_id = chat_log.conversation_id + mock_conversation_input.extra_system_prompt = None + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_log.extra_system_prompt == extra_system_prompt + assert chat_log.content[0].content.endswith(extra_system_prompt) + + # Verify that we take new system prompts + mock_conversation_input.extra_system_prompt = extra_system_prompt2 + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", + ) + ) + + assert chat_log.extra_system_prompt == extra_system_prompt2 + assert chat_log.content[0].content.endswith(extra_system_prompt2) + assert extra_system_prompt not in chat_log.content[0].content + + # Verify that follow-up conversations with no system prompt take previous one + mock_conversation_input.extra_system_prompt = None + + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=None, + user_llm_prompt=None, + ) + + assert chat_log.extra_system_prompt == extra_system_prompt2 + assert chat_log.content[0].content.endswith(extra_system_prompt2) + + +@pytest.mark.parametrize( + "prerun_tool_tasks", + [ + (), + ("mock-tool-call-id",), + ("mock-tool-call-id", "mock-tool-call-id-2"), + ], +) +async def test_tool_call( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + prerun_tool_tasks: tuple[str], +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + with patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + content = AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + ], + ) + + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, + ) + for tool_call_id in prerun_tool_tasks + } + + with pytest.raises(ValueError): + chat_log.async_add_assistant_content_without_tools(content) + + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks or None + ) + ] + + assert results[0] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + assert results[1] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id-2", + tool_result="Test response", + tool_name="test_tool", + ) + + +async def test_tool_call_exception( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.side_effect = HomeAssistantError("Test error") + + with ( + patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + mock_get_tools.return_value = [mock_tool] + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = None + async for tool_result_content in chat_log.async_add_assistant_content( + AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ], + ) + ): + assert result is None + result = tool_result_content + + assert result == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result={"error": "HomeAssistantError", "error_text": "Test error"}, + tool_name="test_tool", + ) + + +@pytest.mark.parametrize( + "deltas", + [ + [], + # With content + [ + {"role": "assistant"}, + {"content": "Test"}, + ], + # With 2 content + [ + {"role": "assistant"}, + {"content": "Test"}, + {"role": "assistant"}, + {"content": "Test 2"}, + ], + # With 1 tool call + [ + {"role": "assistant"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + ], + # With content and 1 tool call + [ + {"role": "assistant"}, + {"content": "Test"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + ], + # With 2 contents and 1 tool call + [ + {"role": "assistant"}, + {"content": "Test"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + {"role": "assistant"}, + {"content": "Test 2"}, + ], + # With 2 tool calls + [ + {"role": "assistant"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param 2"}, + ) + ] + }, + ], + ], +) +async def test_add_delta_content_stream( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, + deltas: list[dict], +) -> None: + """Test streaming deltas.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + + async def tool_call( + hass: HomeAssistant, tool_input: llm.ToolInput, llm_context: llm.LLMContext + ) -> str: + """Call the tool.""" + return tool_input.tool_args["param1"] + + mock_tool.async_call.side_effect = tool_call + expected_delta = [] + + async def stream(): + """Yield deltas.""" + for d in deltas: + yield d + expected_delta.append(d) + + captured_deltas = [] + + with ( + patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log( + hass, + session, + mock_conversation_input, + chat_log_delta_listener=lambda chat_log, delta: captured_deltas.append( + delta + ), + ) as chat_log, + ): + mock_get_tools.return_value = [mock_tool] + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + results = [] + async for content in chat_log.async_add_delta_content_stream( + "mock-agent-id", stream() + ): + results.append(content) + + # Interweave the tool results with the source deltas into expected_delta + if content.role == "tool_result": + expected_delta.append(asdict(content)) + + assert captured_deltas == expected_delta + assert results == snapshot + assert chat_log.content[2:] == results + + +async def test_add_delta_content_stream_errors( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test streaming deltas error handling.""" + + async def stream(deltas): + """Yield deltas.""" + for d in deltas: + yield d + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + # Stream content without LLM API set + with pytest.raises(ValueError): # noqa: PT012 + async for _tool_result_content in chat_log.async_add_delta_content_stream( + "mock-agent-id", + stream( + [ + {"role": "assistant"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={}, + ) + ] + }, + ] + ), + ): + pass + + # Non assistant role + for role in "system", "user": + with pytest.raises(ValueError): # noqa: PT012 + async for ( + _tool_result_content + ) in chat_log.async_add_delta_content_stream( + "mock-agent-id", + stream([{"role": role}]), + ): + pass + + +async def test_chat_log_reuse( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test that we can reuse a chat log.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.conversation_id == session.conversation_id + assert len(chat_log.content) == 1 + + with async_get_chat_log(hass, session) as chat_log2: + assert chat_log2 is chat_log + assert len(chat_log.content) == 1 + + with async_get_chat_log(hass, session, mock_conversation_input) as chat_log2: + assert chat_log2 is chat_log + assert len(chat_log.content) == 2 + assert chat_log.content[1].role == "user" + assert chat_log.content[1].content == mock_conversation_input.text diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 7e05476a349..dca4653b480 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -11,7 +11,7 @@ import pytest from syrupy import SnapshotAssertion import yaml -from homeassistant.components import conversation, cover, media_player +from homeassistant.components import conversation, cover, media_player, weather from homeassistant.components.conversation import default_agent from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE @@ -399,9 +399,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: result = await conversation.async_converse(hass, sentence, None, Context()) assert callback.call_count == 1 assert callback.call_args[0][0].text == sentence - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), sentence + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + sentence + ) assert result.response.speech == { "plain": {"speech": trigger_response, "extra_data": None} } @@ -412,9 +412,9 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: callback.reset_mock() for sentence in test_sentences: result = await conversation.async_converse(hass, sentence, None, Context()) - assert ( - result.response.response_type == intent.IntentResponseType.ERROR - ), sentence + assert result.response.response_type == intent.IntentResponseType.ERROR, ( + sentence + ) assert len(callback.mock_calls) == 0 @@ -3104,3 +3104,186 @@ async def test_turn_on_off( ) assert len(off_calls) == 1 assert off_calls[0].data.get("entity_id") == [entity_id] + + +@pytest.mark.parametrize( + ("error_code", "return_response"), + [ + (intent.IntentResponseErrorCode.NO_INTENT_MATCH, False), + (intent.IntentResponseErrorCode.NO_VALID_TARGETS, False), + (intent.IntentResponseErrorCode.FAILED_TO_HANDLE, True), + (intent.IntentResponseErrorCode.UNKNOWN, True), + ], +) +@pytest.mark.usefixtures("init_components") +async def test_handle_intents_with_response_errors( + hass: HomeAssistant, + init_components: None, + area_registry: ar.AreaRegistry, + error_code: intent.IntentResponseErrorCode, + return_response: bool, +) -> None: + """Test that handle_intents does not return response errors.""" + assert await async_setup_component(hass, "climate", {}) + area_registry.async_create("living room") + + agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + + user_input = ConversationInput( + text="What is the temperature in the living room?", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result", + return_value=default_agent._make_error_result( + user_input.language, error_code, "Mock error message" + ), + ) as mock_process: + response = await agent.async_handle_intents(user_input) + + assert len(mock_process.mock_calls) == 1 + + if return_response: + assert response is not None and response.error_code == error_code + else: + assert response is None + + +@pytest.mark.usefixtures("init_components") +async def test_handle_intents_filters_results( + hass: HomeAssistant, + init_components: None, + area_registry: ar.AreaRegistry, +) -> None: + """Test that handle_intents can filter responses.""" + assert await async_setup_component(hass, "climate", {}) + area_registry.async_create("living room") + + agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + + user_input = ConversationInput( + text="What is the temperature in the living room?", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + mock_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + results = [] + + def _filter_intents(result): + results.append(result) + # We filter first, not 2nd. + return len(results) == 1 + + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize_intent", + return_value=mock_result, + ) as mock_recognize, + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result", + ) as mock_process, + ): + response = await agent.async_handle_intents( + user_input, intent_filter=_filter_intents + ) + + assert len(mock_recognize.mock_calls) == 1 + assert len(mock_process.mock_calls) == 0 + + # It was ignored + assert response is None + + # Check we filtered things + assert len(results) == 1 + assert results[0] is mock_result + + # Second time it is not filtered + response = await agent.async_handle_intents( + user_input, intent_filter=_filter_intents + ) + + assert len(mock_recognize.mock_calls) == 2 + assert len(mock_process.mock_calls) == 2 + + # Check we filtered things + assert len(results) == 2 + assert results[1] is mock_result + + # It was ignored + assert response is not None + + +@pytest.mark.usefixtures("init_components") +async def test_state_names_are_not_translated( + hass: HomeAssistant, + init_components: None, +) -> None: + """Test that state names are not translated in responses.""" + await async_setup_component(hass, "weather", {}) + + hass.states.async_set("weather.test_weather", weather.ATTR_CONDITION_PARTLYCLOUDY) + expose_entity(hass, "weather.test_weather", True) + + with patch( + "homeassistant.helpers.template.Template.async_render" + ) as mock_async_render: + result = await conversation.async_converse( + hass, "what is the weather like?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + mock_async_render.assert_called_once() + + assert ( + mock_async_render.call_args.args[0]["state"].state + == weather.ATTR_CONDITION_PARTLYCLOUDY + ) + + +async def test_language_with_alternative_code( + hass: HomeAssistant, init_components +) -> None: + """Test different codes for the same language.""" + entity_ids: dict[str, str] = {} + for i, (lang_code, sentence, name) in enumerate( + ( + ("no", "slå på lampen", "lampen"), # nb + ("no-NO", "slå på lampen", "lampen"), # nb + ("iw", "הדליקי את המנורה", "מנורה"), # he + ) + ): + if not (entity_id := entity_ids.get(name)): + # Reuse entity id for the same name + entity_id = f"light.test{i}" + entity_ids[name] = entity_id + + hass.states.async_set(entity_id, "off", attributes={ATTR_FRIENDLY_NAME: name}) + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + await hass.services.async_call( + "conversation", + "process", + { + conversation.ATTR_TEXT: sentence, + conversation.ATTR_LANGUAGE: lang_code, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1, f"Failed for {lang_code}, {sentence}" + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": [entity_id]} diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index 109c0ed361f..f03b24818bf 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -6,7 +6,7 @@ from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import mock_restore_cache diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6900ba2d419..9ac5c7d16a4 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -271,6 +271,7 @@ async def test_async_handle_sentence_triggers( text="my trigger", context=Context(), conversation_id=None, + agent_id=conversation.HOME_ASSISTANT_AGENT, device_id=device_id, language=hass.config.language, ), @@ -306,6 +307,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None: ConversationInput( text="I'd like to order a stout", context=Context(), + agent_id=conversation.HOME_ASSISTANT_AGENT, conversation_id=None, device_id=None, language=hass.config.language, @@ -321,6 +323,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None: hass, ConversationInput( text="this sentence does not exist", + agent_id=conversation.HOME_ASSISTANT_AGENT, context=Context(), conversation_id=None, device_id=None, diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py index 7c00b9a80b2..a975c9b7983 100644 --- a/tests/components/conversation/test_trace.py +++ b/tests/components/conversation/test_trace.py @@ -61,18 +61,18 @@ async def test_converation_trace( } -async def test_converation_trace_error( +async def test_converation_trace_uncaught_error( hass: HomeAssistant, init_components: None, sl_setup: None, ) -> None: - """Test tracing a conversation.""" + """Test tracing a conversation that raises an uncaught error.""" with ( patch( "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", - side_effect=HomeAssistantError("Failed to talk to agent"), + side_effect=ValueError("Unexpected error"), ), - pytest.raises(HomeAssistantError), + pytest.raises(ValueError), ): await conversation.async_converse( hass, "add apples to my shopping list", None, Context() @@ -87,4 +87,35 @@ async def test_converation_trace_error( assert ( trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS ) - assert last_trace.get("error") == "Failed to talk to agent" + assert last_trace.get("error") == "Unexpected error" + assert not last_trace.get("result") + + +async def test_converation_trace_homeassistant_error( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation with a HomeAssistant error.""" + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", + side_effect=HomeAssistantError("Failed to talk to agent"), + ), + ): + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + result = last_trace.get("result") + assert result + assert result["response"]["speech"]["plain"]["speech"] == "Failed to talk to agent" diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 9b57bb43b58..3aa8ae2939f 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -5,7 +5,7 @@ import logging import pytest import voluptuous as vol -from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, default_agent from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.models import ConversationInput from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -82,7 +82,7 @@ async def test_if_fires_on_event( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -230,7 +230,7 @@ async def test_response_same_sentence( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -408,7 +408,7 @@ async def test_same_trigger_multiple_sentences( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -636,7 +636,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) }, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 66c2064eb3a..7d84e7ac83e 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -8,6 +8,8 @@ from cookidoo_api import ( CookidooAdditionalItem, CookidooAuthResponse, CookidooIngredientItem, + CookidooSubscription, + CookidooUserInfo, ) import pytest @@ -21,6 +23,8 @@ PASSWORD = "test-password" COUNTRY = "CH" LANGUAGE = "de-CH" +TEST_UUID = "sub_uuid" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -34,16 +38,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_cookidoo_client() -> Generator[AsyncMock]: """Mock a Cookidoo client.""" - with ( - patch( - "homeassistant.components.cookidoo.Cookidoo", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.cookidoo.config_flow.Cookidoo", - new=mock_client, - ), - ): + with patch( + "homeassistant.components.cookidoo.helpers.Cookidoo", + autospec=True, + ) as mock_client: client = mock_client.return_value client.login.return_value = cast(CookidooAuthResponse, {"name": "Cookidoo"}) client.get_ingredient_items.return_value = [ @@ -58,7 +56,15 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] - client.login.return_value = None + client.get_active_subscription.return_value = CookidooSubscription( + **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + ) + client.get_user_info.return_value = CookidooUserInfo( + **load_json_object_fixture("user_info.json", DOMAIN)["data"] + ) + client.login.return_value = CookidooAuthResponse( + **load_json_object_fixture("login.json", DOMAIN) + ) yield client @@ -67,6 +73,8 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: """Mock cookidoo configuration entry.""" return MockConfigEntry( domain=DOMAIN, + version=1, + minor_version=2, data={ CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD, @@ -74,4 +82,5 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: CONF_LANGUAGE: LANGUAGE, }, entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, ) diff --git a/tests/components/cookidoo/fixtures/login.json b/tests/components/cookidoo/fixtures/login.json new file mode 100644 index 00000000000..e7bd6e8716c --- /dev/null +++ b/tests/components/cookidoo/fixtures/login.json @@ -0,0 +1,7 @@ +{ + "access_token": "eyJhbGci", + "expires_in": 43199, + "refresh_token": "eyJhbGciOiJSUzI1NiI", + "token_type": "bearer", + "sub": "sub_uuid" +} diff --git a/tests/components/cookidoo/fixtures/subscriptions.json b/tests/components/cookidoo/fixtures/subscriptions.json new file mode 100644 index 00000000000..12b74b3af08 --- /dev/null +++ b/tests/components/cookidoo/fixtures/subscriptions.json @@ -0,0 +1,12 @@ +{ + "data": { + "active": true, + "start_date": "2024-12-16T00:00:00Z", + "expires": "2025-12-16T23:59:00Z", + "type": "REGULAR", + "extended_type": "REGULAR", + "subscription_level": "FULL", + "subscription_source": "COMMERCE", + "status": "ACTIVE" + } +} diff --git a/tests/components/cookidoo/fixtures/user_info.json b/tests/components/cookidoo/fixtures/user_info.json new file mode 100644 index 00000000000..1c99ae84823 --- /dev/null +++ b/tests/components/cookidoo/fixtures/user_info.json @@ -0,0 +1,7 @@ +{ + "data": { + "username": "username_1234", + "description": null, + "picture": null + } +} diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr index 60f9e95bee7..f316b0cfc82 100644 --- a/tests/components/cookidoo/snapshots/test_button.ambr +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -28,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'todo_clear', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_todo_clear', + 'unique_id': 'sub_uuid_todo_clear', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3dc799c1108 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'additional_items': list([ + dict({ + 'id': 'unique_id_tomaten', + 'is_owned': False, + 'name': 'Tomaten', + }), + ]), + 'ingredient_items': list([ + dict({ + 'description': '200 g', + 'id': 'unique_id_mehl', + 'is_owned': False, + 'name': 'Mehl', + }), + ]), + 'subscription': dict({ + 'active': True, + 'expires': '2025-12-16T23:59:00Z', + 'extended_type': 'REGULAR', + 'start_date': '2024-12-16T00:00:00Z', + 'status': 'ACTIVE', + 'subscription_level': 'FULL', + 'subscription_source': 'COMMERCE', + 'type': 'REGULAR', + }), + }), + 'entry_data': dict({ + 'country': 'CH', + 'email': 'test-email', + 'language': 'de-CH', + 'password': '**REDACTED**', + }), + 'user': dict({ + 'description': None, + 'picture': None, + 'username': 'username_1234', + }), + }) +# --- diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ca861241971 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_sensor.ambr @@ -0,0 +1,108 @@ +# serializer version: 1 +# name: test_setup[sensor.cookidoo_subscription-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_subscription', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Cookidoo Subscription', + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'premium', + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription expiration date', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Cookidoo Subscription expiration date', + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-16T23:59:00+00:00', + }) +# --- diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr index 965cbb0adde..5b2c7552548 100644 --- a/tests/components/cookidoo/snapshots/test_todo.ambr +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -28,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'additional_item_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_additional_items', + 'unique_id': 'sub_uuid_additional_items', 'unit_of_measurement': None, }) # --- @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,7 +77,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'ingredient_list', - 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_ingredients', + 'unique_id': 'sub_uuid_ingredients', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 0057bb3767e..069442517a0 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -200,7 +200,12 @@ async def test_flow_reconfigure_success( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, + user_input={ + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, ) assert result["type"] is FlowResultType.FORM @@ -215,6 +220,8 @@ async def test_flow_reconfigure_success( assert result["reason"] == "reconfigure_successful" assert cookidoo_config_entry.data == { **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", } @@ -340,6 +347,35 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( assert len(hass.config_entries.async_entries()) == 1 +async def test_flow_reconfigure_id_mismatch( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test we abort when the new config is not for the same user.""" + + cookidoo_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" + ) + + result = await cookidoo_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **MOCK_DATA_USER_STEP, + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + CONF_COUNTRY: "DE", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + async def test_flow_reauth( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, @@ -419,46 +455,26 @@ async def test_flow_reauth_error_and_recover( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.parametrize( - ("new_email", "saved_email", "result_reason"), - [ - (EMAIL, EMAIL, "reauth_successful"), - ("another-email", EMAIL, "already_configured"), - ], -) -async def test_flow_reauth_init_data_already_configured( +async def test_flow_reauth_id_mismatch( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, cookidoo_config_entry: MockConfigEntry, - new_email: str, - saved_email: str, - result_reason: str, ) -> None: - """Test we abort user data set when entry is already configured.""" + """Test we abort when the new auth is not for the same user.""" cookidoo_config_entry.add_to_hass(hass) - - another_cookidoo_config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_EMAIL: "another-email", - CONF_PASSWORD: PASSWORD, - CONF_COUNTRY: COUNTRY, - CONF_LANGUAGE: LANGUAGE, - }, + hass.config_entries.async_update_entry( + cookidoo_config_entry, unique_id="some_other_uuid" ) - another_cookidoo_config_entry.add_to_hass(hass) - result = await cookidoo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_EMAIL: new_email, CONF_PASSWORD: PASSWORD}, + {CONF_EMAIL: "new-email", CONF_PASSWORD: PASSWORD}, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == result_reason - assert cookidoo_config_entry.data[CONF_EMAIL] == saved_email + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py new file mode 100644 index 00000000000..c253e1f6e09 --- /dev/null +++ b/tests/components/cookidoo/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Cookidoo integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, cookidoo_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, cookidoo_config_entry) + == snapshot + ) diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py index b1b9b880526..e97bf93bb21 100644 --- a/tests/components/cookidoo/test_init.py +++ b/tests/components/cookidoo/test_init.py @@ -7,9 +7,18 @@ import pytest from homeassistant.components.cookidoo.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration +from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID from tests.common import MockConfigEntry @@ -100,3 +109,229 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert cookidoo_config_entry.state is status + + +MOCK_CONFIG_ENTRY_MIGRATION = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, +} + +OLD_ENTRY_ID = "OLD_OLD_ENTRY_ID" + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + ), + (1, 2, MOCK_CONFIG_ENTRY_MIGRATION, TEST_UUID), + ], +) +async def test_migration_from( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version}", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + + # Check change in config entry and verify most recent version + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == TEST_UUID + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{TEST_UUID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{TEST_UUID}_todo_clear", + ) + ) + ) + + +@pytest.mark.parametrize( + ( + "from_version", + "from_minor_version", + "config_data", + "unique_id", + "login_exception", + ), + [ + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooRequestException, + ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooAuthException, + ), + ], +) +async def test_migration_from_with_error( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + from_version, + from_minor_version, + config_data, + unique_id, + login_exception: Exception, + mock_cookidoo_client: AsyncMock, +) -> None: + """Test different expected migration paths but with connection issues.""" + # Migration can fail due to connection issues as we have to fetch the uuid + mock_cookidoo_client.login.side_effect = login_exception + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config_data, + title=f"MIGRATION_TEST from {from_version}.{from_minor_version} with login exception '{login_exception}'", + version=from_version, + minor_version=from_minor_version, + unique_id=unique_id, + entry_id=OLD_ENTRY_ID, + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, OLD_ENTRY_ID)}, + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_ingredients", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="todo", + unique_id=f"{OLD_ENTRY_ID}_additional_items", + device_id=device.id, + ) + entity_registry.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="button", + unique_id=f"{OLD_ENTRY_ID}_todo_clear", + device_id=device.id, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_ingredients", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.TODO, + DOMAIN, + f"{OLD_ENTRY_ID}_additional_items", + ) + ) + ) + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + ( + Platform.BUTTON, + DOMAIN, + f"{OLD_ENTRY_ID}_todo_clear", + ) + ) + ) diff --git a/tests/components/cookidoo/test_sensor.py b/tests/components/cookidoo/test_sensor.py new file mode 100644 index 00000000000..d2ef88f2857 --- /dev/null +++ b/tests/components/cookidoo/test_sensor.py @@ -0,0 +1,44 @@ +"""Test for sensor platform of the Cookidoo integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_setup( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) diff --git a/tests/components/coolmaster/test_init.py b/tests/components/coolmaster/test_init.py index 4a90d0d9276..f8ff761517f 100644 --- a/tests/components/coolmaster/test_init.py +++ b/tests/components/coolmaster/test_init.py @@ -1,6 +1,5 @@ """The test for the Coolmaster integration.""" -from homeassistant.components.coolmaster.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant @@ -20,8 +19,6 @@ async def test_unload_entry( load_int: ConfigEntry, ) -> None: """Test Coolmaster unloading an entry.""" - assert load_int.entry_id in hass.data.get(DOMAIN) await hass.config_entries.async_unload(load_int.entry_id) await hass.async_block_till_done() assert load_int.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index e6021d22326..7901baaa3b8 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockCover @@ -766,10 +766,7 @@ async def test_if_fires_on_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] @@ -925,10 +922,7 @@ async def test_if_fires_on_tilt_position( ] ) == sorted( [ - ( - f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open" - " - None" - ), + f"is_pos_gt_45_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_lt_90 - device - {entry.entity_id} - closed - open - None", f"is_pos_gt_45 - device - {entry.entity_id} - open - closed - None", ] diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 646c44e4ac2..f1997066638 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -13,7 +13,11 @@ from homeassistant.setup import async_setup_component from .common import MockCover -from tests.common import help_test_all, setup_test_component_platform +from tests.common import ( + MockEntityPlatform, + help_test_all, + setup_test_component_platform, +) async def test_services( @@ -157,13 +161,17 @@ def test_all() -> None: help_test_all(cover) -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockCoverEntity(cover.CoverEntity): _attr_supported_features = 1 entity = MockCoverEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features is cover.CoverEntityFeature(1) assert "MockCoverEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index a38a04cb2ad..c3bb17cb6d6 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -163,7 +163,7 @@ async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): async def start_options_flow( - hass: HomeAssistant, entry_id: str, mocked_manager: MagicMock + hass: HomeAssistant, entry: MockConfigEntry, mocked_manager: MagicMock ): """Patch CrownstoneEntryManager and start the flow.""" # set up integration @@ -171,9 +171,10 @@ async def start_options_flow( "homeassistant.components.crownstone.CrownstoneEntryManager", return_value=mocked_manager, ): - await hass.config_entries.async_setup(entry_id) + await hass.config_entries.async_setup(entry.entry_id) - return await hass.config_entries.options.async_init(entry_id) + entry.runtime_data = mocked_manager + return await hass.config_entries.options.async_init(entry.entry_id) async def test_no_user_input( @@ -413,7 +414,7 @@ async def test_options_flow_setup_usb( result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(2)) ), @@ -490,7 +491,7 @@ async def test_options_flow_remove_usb(hass: HomeAssistant) -> None: result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(2)) ), @@ -543,7 +544,7 @@ async def test_options_flow_manual_usb_path( result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(1)) ), @@ -602,7 +603,7 @@ async def test_options_flow_change_usb_sphere(hass: HomeAssistant) -> None: result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(3)) ), diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 5c432e111dd..612ae7ab649 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -7,12 +7,12 @@ from aiohttp import ClientError, web_exceptions from pydaikin.exceptions import DaikinException import pytest -from homeassistant.components import zeroconf from homeassistant.components.daikin.const import KEY_MAC from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -121,7 +121,7 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: [ ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="mock_hostname", diff --git a/tests/components/deako/snapshots/test_light.ambr b/tests/components/deako/snapshots/test_light.ambr index 7bc170654e1..f5ef5fd19e8 100644 --- a/tests/components/deako/snapshots/test_light.ambr +++ b/tests/components/deako/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +123,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index fd3003b96ef..4a74a673ef8 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -19,9 +19,14 @@ from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker -type ConfigEntryFactoryType = Callable[ - [MockConfigEntry], Coroutine[Any, Any, MockConfigEntry] -] + +class ConfigEntryFactoryType(Protocol): + """Fixture factory that can set up deCONZ config entry.""" + + async def __call__(self, entry: MockConfigEntry = ..., /) -> MockConfigEntry: + """Set up a deCONZ config entry.""" + + type WebsocketDataType = Callable[[dict[str, Any]], Coroutine[Any, Any, None]] type WebsocketStateType = Callable[[str], Coroutine[Any, Any, None]] @@ -203,10 +208,10 @@ async def fixture_config_entry_factory( config_entry: MockConfigEntry, mock_requests: Callable[[str], None], ) -> ConfigEntryFactoryType: - """Fixture factory that can set up UniFi network integration.""" + """Fixture factory that can set up deCONZ integration.""" async def __mock_setup_config_entry( - entry: MockConfigEntry = config_entry, + entry: MockConfigEntry = config_entry, / ) -> MockConfigEntry: entry.add_to_hass(hass) mock_requests(entry.data[CONF_HOST]) diff --git a/tests/components/deconz/snapshots/test_alarm_control_panel.ambr b/tests/components/deconz/snapshots/test_alarm_control_panel.ambr index 86b97a62dfe..e1a6126498c 100644 --- a/tests/components/deconz/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/deconz/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_binary_sensor.ambr b/tests/components/deconz/snapshots/test_binary_sensor.ambr index 584575c23af..6b348d3ed0a 100644 --- a/tests/components/deconz/snapshots/test_binary_sensor.ambr +++ b/tests/components/deconz/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -197,6 +201,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -247,6 +252,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -341,6 +348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -436,6 +445,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -484,6 +494,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -531,6 +542,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -578,6 +590,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -627,6 +640,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +690,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -725,6 +740,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -772,6 +788,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -819,6 +836,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +893,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -925,6 +944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -972,6 +992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_button.ambr b/tests/components/deconz/snapshots/test_button.ambr index 1ef5248ebc3..b7ad00cdacd 100644 --- a/tests/components/deconz/snapshots/test_button.ambr +++ b/tests/components/deconz/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_climate.ambr b/tests/components/deconz/snapshots/test_climate.ambr index 4e33e11534e..f8d572ab2ca 100644 --- a/tests/components/deconz/snapshots/test_climate.ambr +++ b/tests/components/deconz/snapshots/test_climate.ambr @@ -24,6 +24,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +112,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +209,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +297,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -360,6 +364,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -425,6 +430,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -491,6 +497,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_cover.ambr b/tests/components/deconz/snapshots/test_cover.ambr index 5c50923453c..41ff4e950a8 100644 --- a/tests/components/deconz/snapshots/test_cover.ambr +++ b/tests/components/deconz/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index 1ca674a4fbe..20558b4bbbd 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/deconz/snapshots/test_fan.ambr b/tests/components/deconz/snapshots/test_fan.ambr index 8b7dbba64e4..6a260c39673 100644 --- a/tests/components/deconz/snapshots/test_fan.ambr +++ b/tests/components/deconz/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index f3aa9a5e65d..06067b69c17 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ }), diff --git a/tests/components/deconz/snapshots/test_light.ambr b/tests/components/deconz/snapshots/test_light.ambr index b73bbcca216..212ccd84d0c 100644 --- a/tests/components/deconz/snapshots/test_light.ambr +++ b/tests/components/deconz/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,6 +76,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +162,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -238,6 +241,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +318,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +384,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -464,6 +470,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -542,6 +549,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -618,6 +626,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -683,6 +692,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -768,6 +778,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -846,6 +857,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -931,6 +943,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1035,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1122,6 +1136,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1215,6 +1230,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1291,6 +1307,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1348,6 +1365,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1436,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_number.ambr b/tests/components/deconz/snapshots/test_number.ambr index 26e044e1d31..173d5e87043 100644 --- a/tests/components/deconz/snapshots/test_number.ambr +++ b/tests/components/deconz/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_scene.ambr b/tests/components/deconz/snapshots/test_scene.ambr index 85a5ab92c5c..21456afaea1 100644 --- a/tests/components/deconz/snapshots/test_scene.ambr +++ b/tests/components/deconz/snapshots/test_scene.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_select.ambr b/tests/components/deconz/snapshots/test_select.ambr index 997eab0901f..7fa2aaf11cb 100644 --- a/tests/components/deconz/snapshots/test_select.ambr +++ b/tests/components/deconz/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -180,6 +183,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +240,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +355,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +412,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -462,6 +470,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +532,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_sensor.ambr b/tests/components/deconz/snapshots/test_sensor.ambr index 0b76366b5d1..be397f0e22a 100644 --- a/tests/components/deconz/snapshots/test_sensor.ambr +++ b/tests/components/deconz/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,6 +262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -305,6 +311,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +360,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -401,6 +409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +459,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -505,6 +515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -557,6 +568,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -611,6 +623,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +676,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -717,6 +731,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -771,6 +786,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -822,6 +838,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -876,6 +893,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -928,6 +946,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -980,6 +999,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1035,6 +1055,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1085,6 +1106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1134,6 +1156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1186,6 +1209,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1239,6 +1263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1290,6 +1315,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1341,6 +1367,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1392,6 +1419,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1443,6 +1471,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1493,6 +1522,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1544,6 +1574,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1600,6 +1631,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1651,6 +1683,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1702,6 +1735,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1753,6 +1787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1803,6 +1838,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1854,6 +1890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1905,6 +1942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1956,6 +1994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2006,6 +2045,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2058,6 +2098,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2109,6 +2150,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2160,6 +2202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2211,6 +2254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ce13bbfa5d4..fe5fe022427 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pydeconz import pytest -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import ( CONF_MANUAL_INPUT, CONF_SERIAL, @@ -20,12 +19,16 @@ from homeassistant.components.deconz.const import ( DOMAIN as DECONZ_DOMAIN, HASSIO_CONFIGURATION_URL, ) -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from .conftest import API_KEY, BRIDGE_ID @@ -435,7 +438,7 @@ async def test_flow_ssdp_discovery( """Test that config flow for one discovered bridge works.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -483,7 +486,7 @@ async def test_ssdp_discovery_update_configuration( ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.3.4.5:80/", @@ -509,7 +512,7 @@ async def test_ssdp_discovery_dont_update_configuration( result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -533,7 +536,7 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration( """Test to ensure the SSDP discovery does not update an Hass.io entry.""" result = await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index 43c51179337..1b000828b85 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -6,18 +6,18 @@ from pydeconz.websocket import State import pytest from syrupy import SnapshotAssertion -from homeassistant.components import ssdp from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER_URL, - ATTR_UPNP_SERIAL, - ATTR_UPNP_UDN, -) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import BRIDGE_ID @@ -81,7 +81,7 @@ async def test_update_address( ): await hass.config_entries.flow.async_init( DECONZ_DOMAIN, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_st="mock_st", ssdp_usn="mock_usn", ssdp_location="http://2.3.4.5:80/", diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 97cad5bbe14..dcec921c01d 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py index d3c2937d12b..a93c79828d6 100644 --- a/tests/components/demo/test_geo_location.py +++ b/tests/components/demo/test_geo_location.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 98b3de8448a..f3677c6e373 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -6,8 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components import notify -from homeassistant.components.demo import DOMAIN -import homeassistant.components.demo.notify as demo +from homeassistant.components.demo import DOMAIN, notify as demo from homeassistant.const import Platform from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/demo/test_valve.py b/tests/components/demo/test_valve.py new file mode 100644 index 00000000000..1057065ce70 --- /dev/null +++ b/tests/components/demo/test_valve.py @@ -0,0 +1,83 @@ +"""The tests for the Demo valve platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.demo import DOMAIN, valve as demo_valve +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + ValveState, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +FRONT_GARDEN = "valve.front_garden" +ORCHARD = "valve.orchard" + + +@pytest.fixture +async def valve_only() -> None: + """Enable only the valve platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VALVE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, valve_only: None): + """Set up demo component.""" + assert await async_setup_component( + hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_closing(hass: HomeAssistant) -> None: + """Test the closing of a valve.""" + state = hass.states.get(FRONT_GARDEN) + assert state.state == ValveState.OPEN + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: FRONT_GARDEN}, + blocking=False, + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == FRONT_GARDEN + assert state_changes[0].data["new_state"].state == ValveState.CLOSING + + assert state_changes[1].data["entity_id"] == FRONT_GARDEN + assert state_changes[1].data["new_state"].state == ValveState.CLOSED + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a valve.""" + state = hass.states.get(ORCHARD) + assert state.state == ValveState.CLOSED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: ORCHARD}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == ORCHARD + assert state_changes[0].data["new_state"].state == ValveState.OPENING + + assert state_changes[1].data["entity_id"] == ORCHARD + assert state_changes[1].data["new_state"].state == ValveState.OPEN diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index 48859610d39..257e1ab5ffb 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -43,6 +43,7 @@ async def test_setup_params(hass: HomeAssistant) -> None: assert state.attributes.get("temperature") == 119 assert state.attributes.get("away_mode") == "off" assert state.attributes.get("operation_mode") == "eco" + assert state.attributes.get("target_temp_step") == 1 async def test_default_setup_params(hass: HomeAssistant) -> None: diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py index 324b795052c..92fe381ac4d 100644 --- a/tests/components/denonavr/test_config_flow.py +++ b/tests/components/denonavr/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.denonavr.config_flow import ( CONF_MANUFACTURER, CONF_SERIAL_NUMBER, @@ -21,6 +20,12 @@ from homeassistant.components.denonavr.config_flow import ( from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -313,14 +318,14 @@ async def test_config_flow_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -353,14 +358,14 @@ async def test_config_flow_ssdp_not_denon(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", - ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: "NotSupported", + ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) @@ -377,12 +382,12 @@ async def test_config_flow_ssdp_missing_info(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, }, ), ) @@ -399,14 +404,14 @@ async def test_config_flow_ssdp_ignored_model(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=TEST_SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, - ssdp.ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, - ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: TEST_IGNORED_MODEL, + ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, }, ), ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 4a4d8519b25..f8d88066f16 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}, force_update=True) + hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" +async def test_no_change(hass: HomeAssistant) -> None: + """Test derivative sensor state updated when source sensor doesn't change.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.energy", + "unit": "kW", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set(entity_id, 0, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.derivative") + assert state is not None + + # Testing a energy sensor at 1 kWh for 1hour = 0kW + assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + assert state.attributes.get("unit_of_measurement") == "kW" + + assert state.last_changed == base + timedelta(seconds=2 * 3600) + + async def _setup_sensor( hass: HomeAssistant, config: dict[str, Any] ) -> tuple[dict[str, Any], str]: @@ -86,7 +129,7 @@ async def setup_tests( with freeze_time(base) as freezer: for time, value in zip(times, values, strict=False): freezer.move_to(base + timedelta(seconds=time)) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) +async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: + """Test that zeroes are properly handled within the time window.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 10 minutes long. Then, it + # stays constant for another 10 minutes. There is a data point every + # minute and we use a time window of 10 minutes. + # Therefore, we can expect the derivative to peak at 1 after 10 minutes + # and then fall down to 0 in steps of 10%. + + temperature_values = [] + for temperature in range(10): + temperature_values += [temperature] + temperature_values += [10] * 11 + time_window = 600 + times = list(range(0, 1200 + 60, 60)) + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.MINUTES, + "round": 1, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_derivative = 0 + for time, value in zip(times, temperature_values, strict=True): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative + + async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: """Test derivative sensor state.""" # We simulate the following situation: @@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time < times[-1] - time_window: @@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time and time > times[3]: @@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) @@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id, 1000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, - 1000, + 2000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") assert state is not None - # Testing a power sensor at 1000 Watts for 1hour = 0kW/h - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h + assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" @@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1000, {}, force_update=True) + hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: entity_id, value, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, - force_update=True, ) await hass.async_block_till_done() diff --git a/tests/components/devialet/__init__.py b/tests/components/devialet/__init__.py index 28ab6229c44..08ccaffa92d 100644 --- a/tests/components/devialet/__init__.py +++ b/tests/components/devialet/__init__.py @@ -5,10 +5,10 @@ from ipaddress import ip_address from aiohttp import ClientError as ServerTimeoutError from devialet.const import UrlSuffix -from homeassistant.components import zeroconf from homeassistant.components.devialet.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +25,7 @@ CONF_DATA = { MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} MOCK_USER_INPUT = {CONF_HOST: HOST} -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(HOST), ip_addresses=[ip_address(HOST)], hostname="PhantomISilver-L00P00000AB11.local.", diff --git a/tests/components/devialet/fixtures/general_info.json b/tests/components/devialet/fixtures/general_info.json index 6ff1a724f08..3efe8ae2080 100644 --- a/tests/components/devialet/fixtures/general_info.json +++ b/tests/components/devialet/fixtures/general_info.json @@ -1,18 +1,22 @@ { + "availableFeatures": ["powerManagement"], "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", "deviceName": "Livingroom", "firmwareFamily": "DOS", "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "installationId": "abc1eca1-1234-5251-a123-12ac1a3e9fe8", "ipControlVersion": "1", + "isSystemLeader": true, "model": "Phantom I Silver", + "modelFamily": "Phantom I", + "powerRating": "105 dB", "release": { "buildType": "release", - "canonicalVersion": "2.16.1.49152", - "version": "2.16.1" + "canonicalVersion": "2.17.6.49152", + "version": "2.17.6" }, "role": "FrontLeft", "serial": "L00P00000AB11", - "standbyEntryDelay": 0, - "standbyState": "Unknown", + "setupState": "ongoing", "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl" } diff --git a/tests/components/devialet/fixtures/source_state.json b/tests/components/devialet/fixtures/source_state.json index d389675ac98..f24575cd264 100644 --- a/tests/components/devialet/fixtures/source_state.json +++ b/tests/components/devialet/fixtures/source_state.json @@ -1,5 +1,5 @@ { - "availableOptions": ["play", "pause", "previous", "next", "seek"], + "availableOperations": ["play", "pause", "previous", "next", "seek"], "metadata": { "album": "1 (Remastered)", "artist": "The Beatles", diff --git a/tests/components/devialet/fixtures/system_info.json b/tests/components/devialet/fixtures/system_info.json index f496e5557d2..a43b4e83901 100644 --- a/tests/components/devialet/fixtures/system_info.json +++ b/tests/components/devialet/fixtures/system_info.json @@ -1,6 +1,29 @@ { - "availableFeatures": ["nightMode", "equalizer", "balance"], + "availableFeatures": [ + "nightMode", + "equalizer", + "balance", + "preferredInterfaceControl" + ], + "devices": [ + { + "deviceId": "1abcdef2-3456-67g8-9h0i-1jk23456lm78", + "isSystemLeader": true, + "name": "Livingroom", + "role": null, + "serial": "L05P00066EW14" + }, + { + "deviceId": "1zzyyxx2-3456-67g8-9h0i-1zz23456lz78", + "isSystemLeader": false, + "name": "Phantom I Silver-123a", + "role": null, + "serial": "M05P00066EW15" + } + ], "groupId": "12345678-901a-2b3c-def4-567g89h0i12j", + "multiroomFamily": "sync33", "systemId": "a12b345c-67d8-90e1-12f4-g5hij67890kl", - "systemName": "Devialet" + "systemName": "Devialet", + "systemType": "stereo" } diff --git a/tests/components/devialet/test_diagnostics.py b/tests/components/devialet/test_diagnostics.py index 97c23efe713..6bf643ce682 100644 --- a/tests/components/devialet/test_diagnostics.py +++ b/tests/components/devialet/test_diagnostics.py @@ -31,11 +31,13 @@ async def test_diagnostics( "source_list": [ "Airplay", "Bluetooth", - "Online", "Optical left", "Optical right", "Raat", "Spotify Connect", + "UPnP", ], "source": "spotifyconnect", + "upnp_device_type": "Not available", + "upnp_device_url": "Not available", } diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py index a87e8ac05c3..6808ee0983e 100644 --- a/tests/components/devialet/test_init.py +++ b/tests/components/devialet/test_init.py @@ -1,6 +1,5 @@ """Test the Devialet init.""" -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -16,7 +15,6 @@ async def test_load_unload_config_entry( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -26,7 +24,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -36,7 +33,6 @@ async def test_load_unload_config_entry_when_device_unavailable( """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" entry = await setup_integration(hass, aioclient_mock, state="unavailable") - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -46,5 +42,4 @@ async def test_load_unload_config_entry_when_device_unavailable( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py index 4e8f9b1dc03..fd593a10a98 100644 --- a/tests/components/devialet/test_media_player.py +++ b/tests/components/devialet/test_media_player.py @@ -6,7 +6,6 @@ from devialet import DevialetApi from devialet.const import UrlSuffix from yarl import URL -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.components.media_player import ( @@ -96,7 +95,7 @@ SERVICE_TO_DATA = { ], SERVICE_SELECT_SOURCE: [ {ATTR_INPUT_SOURCE: "Optical left"}, - {ATTR_INPUT_SOURCE: "Online"}, + {ATTR_INPUT_SOURCE: "UPnP"}, ], } @@ -108,7 +107,6 @@ async def test_media_player_playing( await async_setup_component(hass, "homeassistant", {}) entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.services.async_call( @@ -203,7 +201,7 @@ async def test_media_player_playing( ) with patch.object( - DevialetApi, "available_options", new_callable=PropertyMock + DevialetApi, "available_operations", new_callable=PropertyMock ) as mock: mock.return_value = None await hass.config_entries.async_reload(entry.entry_id) @@ -227,7 +225,6 @@ async def test_media_player_playing( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -237,7 +234,6 @@ async def test_media_player_offline( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") @@ -247,7 +243,6 @@ async def test_media_player_offline( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -257,14 +252,12 @@ async def test_media_player_without_serial( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, serial=None) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -276,7 +269,6 @@ async def test_media_player_services( hass, aioclient_mock, state=MediaPlayerState.PLAYING ) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} @@ -309,5 +301,4 @@ async def test_media_player_services( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index be4d3bd4c9e..a7b2f8a3b75 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index bc721803450..fa1e65ded51 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -35,7 +35,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -114,7 +114,7 @@ async def create_mock_platform( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index e73c18919c5..ea07365bd2f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -28,7 +28,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import common from .common import MockScanner, mock_legacy_device_tracker_setup @@ -159,9 +159,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: ] legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device MAC" in args[0], "Duplicate MAC warning expected" @@ -177,9 +177,9 @@ async def test_duplicate_mac_dev_id(mock_warning, hass: HomeAssistant) -> None: legacy.DeviceTracker(hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) - assert ( - mock_warning.call_count == 1 - ), "The only warning call should be duplicates (check DEBUG)" + assert mock_warning.call_count == 1, ( + "The only warning call should be duplicates (check DEBUG)" + ) args, _ = mock_warning.call_args assert "Duplicate device IDs" in args[0], "Duplicate device IDs warning expected" diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 3351e42c988..06e7a8bcd9c 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], port=14791, @@ -22,7 +22,7 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( }, ) -DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", @@ -32,7 +32,7 @@ DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.1"), ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr index c5daed73b33..659420c1590 100644 --- a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index be7d6f78142..96ffe45c4a4 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -35,6 +35,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr index 7d88d42d5c2..44bff626923 100644 --- a/tests/components/devolo_home_control/snapshots/test_cover.ambr +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -22,6 +22,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index abedc128756..0e507ca0b28 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '123456', 'version': 1, diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr index 959656b52a4..11dc768a519 100644 --- a/tests/components/devolo_home_control/snapshots/test_light.ambr +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -29,6 +29,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr index 3c23385594a..7cca8b23e77 100644 --- a/tests/components/devolo_home_control/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +75,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +231,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr index 5c94674998c..41b68574065 100644 --- a/tests/components/devolo_home_control/snapshots/test_siren.ambr +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -27,6 +27,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -135,6 +137,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr index 3e2f6f705d3..d3097716092 100644 --- a/tests/components/devolo_home_control/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index fd03063cd34..2b3fd989754 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -27,6 +27,13 @@ def mock_repeater_device(mock_device: MockDevice): return mock_device +@pytest.fixture +def mock_ipv6_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device using IPv6.""" + mock_device.ip = "2001:db8::1" + return mock_device + + @pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 7b0551b1daf..f3c469e61b2 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -14,7 +14,7 @@ from devolo_plc_api.device_api import ( ) from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo IP = "192.0.2.1" IP_ALT = "192.0.2.2" diff --git a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr index c0df0d5d5a5..a33fdf084dd 100644 --- a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index 126ac4e7cdb..31d8ebf31a0 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 53940bf5119..1288b7f3ef6 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -32,6 +32,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '1234567890', 'version': 1, diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index b3924a508cf..3772672d8cb 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 297c9a25183..5753fd82817 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ tuple( @@ -35,10 +36,48 @@ 'via_device_id': None, }) # --- +# name: test_setup_entry[mock_ipv6_device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://[2001:db8::1]', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'model_id': '2730', + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- # name: test_setup_entry[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ }), diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 2e6730cdb21..9e2d8879ac9 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -115,6 +117,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +265,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index a2df5d2579f..6499bb9a17b 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr index 8a1065f9a60..f4d1c0480cf 100644 --- a/tests/components/devolo_home_network/snapshots/test_update.ambr +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -32,6 +32,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 71823eabe82..56d2c21a5b2 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -27,7 +27,9 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.parametrize("device", ["mock_device", "mock_repeater_device"]) +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) async def test_setup_entry( hass: HomeAssistant, device: str, diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 6852f4369cc..223dc83f83a 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -31,16 +31,18 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) @@ -1353,3 +1355,30 @@ async def test_dhcp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_init.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "DhcpServiceInfo", + "homeassistant.helpers.service_info.dhcp.DhcpServiceInfo", + DhcpServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + dhcp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index ae22e280000..48a334611d3 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -2,10 +2,10 @@ from http import HTTPStatus -from homeassistant.components import ssdp from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN from homeassistant.const import CONF_HOST, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -16,7 +16,7 @@ SSDP_LOCATION = "http://127.0.0.1/" UPNP_SERIAL = "RID-028877455858" MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index ad22aa871b7..b698873d1e9 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -6,11 +6,11 @@ from unittest.mock import patch from aiohttp import ClientError as HTTPClientError from homeassistant.components.directv.const import CONF_RECEIVER_ID, DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_SERIAL from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL from . import ( HOST, diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py index 4bfe8e2121f..102c338e757 100644 --- a/tests/components/directv/test_init.py +++ b/tests/components/directv/test_init.py @@ -1,6 +1,5 @@ """Tests for the DirecTV integration.""" -from homeassistant.components.directv.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,11 +23,9 @@ async def test_unload_config_entry( """Test the DirecTV configuration entry unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index b4831d81bda..866a57c8dda 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -44,6 +45,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -188,6 +192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index c56b93c4d3d..d59e06ef444 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -6,11 +6,11 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -29,13 +29,13 @@ CONF_DHCP_DATA = { CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", ) -CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW_NEW_IP = DhcpServiceInfo( ip="5.6.7.8", macaddress=DHCP_FORMATTED_MAC, hostname="dsp-w215", diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index b6f025bb5b0..0449f68263c 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import MagicMock, patch -from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( CONF_DATA, @@ -160,7 +160,7 @@ async def test_dhcp_unique_id_assignment( hass: HomeAssistant, mocked_plug: MagicMock ) -> None: """Test dhcp initialized flow with no unique id for matching entry.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="2.3.4.5", macaddress="11:22:33:44:55:66", hostname="dsp-w215", diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index cb32001e1e5..e02baceb380 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -12,7 +12,6 @@ from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dmr.const import ( CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, @@ -23,6 +22,15 @@ from homeassistant.components.dlna_dmr.const import ( from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST_ADDR, @@ -48,17 +56,17 @@ CHANGED_DEVICE_UDN = "uuid:7cc6da13-7f5d-4ace-9729-badbadbadbad" MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, ssdp_headers={"_host": MOCK_DEVICE_HOST_ADDR}, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/AVTransport/scpd.xml", @@ -358,15 +366,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -492,15 +500,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -514,7 +522,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -526,7 +534,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -538,10 +546,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # AVTransport service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:AVTransport" ] } @@ -560,10 +568,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() - service_list = discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST].copy() + service_list = discovery.upnp[ATTR_UPNP_SERVICE_LIST].copy() # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, @@ -589,9 +597,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_DEVICE_TYPE] = ( - "urn:schemas-upnp-org:device:ZonePlayer:1" - ) + discovery.upnp[ATTR_UPNP_DEVICE_TYPE] = "urn:schemas-upnp-org:device:ZonePlayer:1" result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -608,8 +614,8 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: ): discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer - discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model + discovery.upnp[ATTR_UPNP_MANUFACTURER] = manufacturer + discovery.upnp[ATTR_UPNP_MODEL_NAME] = model result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 3d8f9da8ed9..a92f7807912 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -47,6 +47,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.setup import async_setup_component from .conftest import ( @@ -1413,7 +1414,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1484,7 +1485,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1506,7 +1507,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1525,7 +1526,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1546,7 +1547,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. domain_data_mock.upnp_factory.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -1554,7 +1555,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1597,7 +1598,7 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1606,7 +1607,7 @@ async def test_multiple_ssdp_alive( ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -1637,7 +1638,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1656,7 +1657,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -1689,7 +1690,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1702,7 +1703,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1727,7 +1728,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1752,7 +1753,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1777,7 +1778,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -1816,7 +1817,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1829,7 +1830,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -1854,7 +1855,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -1893,7 +1894,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1913,7 +1914,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -1933,7 +1934,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -2354,7 +2355,7 @@ async def test_connections_restored( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 14da36a0381..76890f328e4 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -12,11 +12,17 @@ from async_upnp_client.exceptions import UpnpError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.dlna_dms.const import CONF_SOURCE_ID, DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( MOCK_DEVICE_HOST, @@ -35,16 +41,16 @@ WRONG_DEVICE_TYPE: Final = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" MOCK_ROOT_DEVICE_UDN: Final = "ROOT_DEVICE" -MOCK_DISCOVERY: Final = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY: Final = SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, - ssdp.ATTR_UPNP_SERVICE_LIST: { + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_SERVICE_LIST: { "service": [ { "SCPDURL": "/ContentDirectory/scpd.xml", @@ -195,15 +201,15 @@ async def test_ssdp_flow_existing( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st="mock_st", ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, upnp={ - ssdp.ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: MOCK_ROOT_DEVICE_UDN, + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -279,7 +285,7 @@ async def test_duplicate_name( ssdp_udn=new_device_udn, ) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_UDN] = new_device_udn + discovery.upnp[ATTR_UPNP_UDN] = new_device_udn result = await hass.config_entries.flow.async_init( DOMAIN, @@ -312,15 +318,15 @@ async def test_ssdp_flow_upnp_udn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_udn=MOCK_DEVICE_UDN, ssdp_st=MOCK_DEVICE_TYPE, upnp={ - ssdp.ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", - ssdp.ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, + ATTR_UPNP_UDN: "DIFFERENT_ROOT_DEVICE", + ATTR_UPNP_DEVICE_TYPE: MOCK_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME: MOCK_DEVICE_NAME, }, ), ) @@ -334,7 +340,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] + del discovery.upnp[ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -346,7 +352,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # Service list does not contain services discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -358,10 +364,10 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # ContentDirectory service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = { "service": [ service - for service in discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]["service"] + for service in discovery.upnp[ATTR_UPNP_SERVICE_LIST]["service"] if service.get("serviceId") != "urn:upnp-org:serviceId:ContentDirectory" ] } @@ -380,10 +386,10 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: """ discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) - service_list = dict(discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]) + service_list = dict(discovery.upnp[ATTR_UPNP_SERVICE_LIST]) # Turn mock's list of service dicts into a single dict service_list["service"] = service_list["service"][0] - discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + discovery.upnp[ATTR_UPNP_SERVICE_LIST] = service_list result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index 1be68f91733..01976c16247 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -18,6 +18,7 @@ from homeassistant.components.dlna_dms.dms import get_domain_data from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_LOCATION, @@ -179,7 +180,7 @@ async def test_become_available( # Send an SSDP notification from the now alive device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -207,7 +208,7 @@ async def test_alive_but_gone( # Send an SSDP notification from the still missing device ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -227,7 +228,7 @@ async def test_alive_but_gone( # Send the same SSDP notification, expecting no extra connection attempts upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -244,7 +245,7 @@ async def test_alive_but_gone( # Send an SSDP notification with a new BOOTID, indicating the device has rebooted upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -263,7 +264,7 @@ async def test_alive_but_gone( # should result in a reconnect attempt even with same BOOTID. upnp_factory_mock.async_create_device.reset_mock() await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_st=MOCK_DEVICE_TYPE, upnp={}, @@ -271,7 +272,7 @@ async def test_alive_but_gone( ssdp.SsdpChange.BYEBYE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -310,7 +311,7 @@ async def test_multiple_ssdp_alive( # Send two SSDP notifications with the new device URL ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -319,7 +320,7 @@ async def test_multiple_ssdp_alive( ssdp.SsdpChange.ALIVE, ) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, ssdp_st=MOCK_DEVICE_TYPE, @@ -345,7 +346,7 @@ async def test_ssdp_byebye( # First byebye will cause a disconnect ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -360,7 +361,7 @@ async def test_ssdp_byebye( # Second byebye will do nothing await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, @@ -388,7 +389,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -405,7 +406,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -426,7 +427,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with same next boot ID, again await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -447,7 +448,7 @@ async def test_ssdp_update_seen_bootid( # Send SSDP update with bad next boot ID await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -468,7 +469,7 @@ async def test_ssdp_update_seen_bootid( # Send a new SSDP alive with the new boot ID, device should not reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, @@ -500,7 +501,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -517,7 +518,7 @@ async def test_ssdp_update_missed_bootid( # Send SSDP update with skipped boot ID (not previously seen) await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={ @@ -538,7 +539,7 @@ async def test_ssdp_update_missed_bootid( # Send a new SSDP alive with the new boot ID, device should reconnect await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, @@ -570,7 +571,7 @@ async def test_ssdp_bootid( # Send SSDP alive with boot ID ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -586,7 +587,7 @@ async def test_ssdp_bootid( # Send SSDP alive with same boot ID, nothing should happen await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, @@ -602,7 +603,7 @@ async def test_ssdp_bootid( # Send a new SSDP alive with an incremented boot ID, device should be dis/reconnected await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 7907d40c415..5576066f781 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -16,6 +16,7 @@ from homeassistant.components.dlna_dms.dms import DidlPlayMedia from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import BrowseMediaSource, Unresolvable from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ( MOCK_DEVICE_BASE_URL, @@ -68,7 +69,7 @@ async def test_catch_request_error_unavailable( # DmsDevice notifies of disconnect via SSDP ssdp_callback = ssdp_scanner_mock.async_register_callback.call_args.args[0].target await ssdp_callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 3abdd2b87a3..98b2189dfd9 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -8,7 +8,6 @@ from doorbirdpy import DoorBird import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.doorbird.const import ( CONF_EVENTS, DEFAULT_DOORBELL_EVENT, @@ -18,6 +17,7 @@ from homeassistant.components.doorbird.const import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( VALID_CONFIG, @@ -74,7 +74,7 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.8"), ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", @@ -94,7 +94,7 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("169.254.103.61"), ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", @@ -121,7 +121,7 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -142,7 +142,7 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -164,7 +164,7 @@ async def test_form_zeroconf_correct_oui( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_form_zeroconf_correct_oui_wrong_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 6b008c7fac1..fda1ecc6cf6 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr index 9b0cc201573..8d83482e208 100644 --- a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +436,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 2301b9dfc80..769e98f6db4 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -45,9 +45,9 @@ def dsmr_connection_fixture() -> Generator[tuple[MagicMock, MagicMock, MagicMock @pytest.fixture -def rfxtrx_dsmr_connection_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks RFXtrx connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -73,9 +73,9 @@ def rfxtrx_dsmr_connection_fixture() -> ( @pytest.fixture -def dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) @@ -113,6 +113,12 @@ def dsmr_connection_send_validate_fixture() -> ( EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] ), } + if args[1] == "5EONHU": + protocol.telegram = { + LUXEMBOURG_EQUIPMENT_IDENTIFIER: CosemObject( + LUXEMBOURG_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + } if args[1] == "5S": protocol.telegram = { P1_MESSAGE_TIMESTAMP: CosemObject( @@ -156,9 +162,9 @@ def dsmr_connection_send_validate_fixture() -> ( @pytest.fixture -def rfxtrx_dsmr_connection_send_validate_fixture() -> ( - Generator[tuple[MagicMock, MagicMock, MagicMock]] -): +def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[ + tuple[MagicMock, MagicMock, MagicMock] +]: """Fixture that mocks serial connection.""" transport = MagicMock(spec=asyncio.Transport) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 91adf38eacf..961c9831f44 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -163,6 +163,16 @@ async def test_setup_network_rfxtrx( "serial_id_gas": "123456789", }, ), + ( + "5EONHU", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5EONHU", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": None, + }, + ), ( "5S", { diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index 8fc996f6e34..9bcde251f6f 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -58,7 +58,7 @@ async def test_diagnostics( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m³"}, + {"value": Decimal("745.695"), "unit": "m³"}, ], ), "GAS_METER_READING", diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 7c7d182aa97..d590666b060 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -10,6 +10,7 @@ from dsmr_parser.obis_references import ( MBUS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram +import pytest from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -85,7 +86,7 @@ async def test_migrate_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -102,6 +103,17 @@ async def test_migrate_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -185,7 +197,7 @@ async def test_migrate_hourly_gas_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1722749707)}, - {"value": Decimal(778.963), "unit": "m3"}, + {"value": Decimal("778.963"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -202,6 +214,17 @@ async def test_migrate_hourly_gas_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is created and the old device has been removed + assert len(device_registry.devices) == 1 + assert not device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + new_dev_entities = er.async_entries_for_device( + entity_registry, new_device.id, include_disabled_entities=True + ) + assert new_dev_entities == [new_entity] + + # Check no entities are connected to the old device dev_entities = er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True ) @@ -285,7 +308,7 @@ async def test_migrate_gas_with_devid_to_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -302,6 +325,18 @@ async def test_migrate_gas_with_devid_to_mbus( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 1 + assert device_registry.async_get(device.id) + new_entity = entity_registry.async_get("sensor.gas_meter_reading") + new_device = device_registry.async_get(new_entity.device_id) + assert new_device.id == device.id + # Check entities are still connected to the old device + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [new_entity] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) is None @@ -319,6 +354,7 @@ async def test_migrate_gas_to_mbus_exists( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], + caplog: pytest.LogCaptureFixture, ) -> None: """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -380,7 +416,7 @@ async def test_migrate_gas_to_mbus_exists( telegram = Telegram() telegram.add( MBUS_DEVICE_TYPE, - CosemObject((0, 0), [{"value": "003", "unit": ""}]), + CosemObject((0, 1), [{"value": "003", "unit": ""}]), "MBUS_DEVICE_TYPE", ) telegram.add( @@ -397,7 +433,7 @@ async def test_migrate_gas_to_mbus_exists( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -414,7 +450,32 @@ async def test_migrate_gas_to_mbus_exists( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() + # Check a new device is not created and the old device has not been removed + assert len(device_registry.devices) == 2 + assert device_registry.async_get(device.id) + assert device_registry.async_get(device2.id) + entity = entity_registry.async_get("sensor.gas_meter_reading") + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert dev_entities == [entity] + entity2 = entity_registry.async_get("sensor.gas_meter_reading_alt") + dev2_entities = er.async_entries_for_device( + entity_registry, device2.id, include_disabled_entities=True + ) + assert dev2_entities == [entity2] + assert ( entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) == "sensor.gas_meter_reading" ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading_alt" + ) + assert ( + "Skip migration of sensor.gas_meter_reading because it already exists" + in caplog.text + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 4a2951f4ed8..5657c5999ce 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -89,7 +89,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.695"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -152,7 +152,7 @@ async def test_default_setup( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS}, + {"value": Decimal("745.701"), "unit": UnitOfVolume.CUBIC_METERS}, ], ), "GAS_METER_READING", @@ -279,7 +279,7 @@ async def test_v4_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -336,9 +336,9 @@ async def test_v4_meter( @pytest.mark.parametrize( ("value", "state"), [ - (Decimal(745.690), "745.69"), - (Decimal(745.695), "745.695"), - (Decimal(0.000), STATE_UNKNOWN), + (Decimal("745.690"), "745.69"), + (Decimal("745.695"), "745.695"), + (Decimal("0.000"), STATE_UNKNOWN), ], ) async def test_v5_meter( @@ -440,7 +440,7 @@ async def test_luxembourg_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "HOURLY_GAS_METER_READING", @@ -449,7 +449,7 @@ async def test_luxembourg_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -457,7 +457,7 @@ async def test_luxembourg_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -512,6 +512,76 @@ async def test_luxembourg_meter( ) +async def test_eonhu_meter( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """Test if v5 meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5EONHU", + "serial_id": "1234", + } + entry_options = { + "time_between_update": 0, + } + + telegram = Telegram() + telegram.add( + ELECTRICITY_IMPORTED_TOTAL, + CosemObject( + (0, 0), + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + ), + "ELECTRICITY_IMPORTED_TOTAL", + ) + telegram.add( + ELECTRICITY_EXPORTED_TOTAL, + CosemObject( + (0, 0), + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + ), + "ELECTRICITY_EXPORTED_TOTAL", + ) + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") + assert active_tariff.state == "123.456" + assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert ( + active_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfEnergy.KILO_WATT_HOUR + ) + + active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") + assert active_tariff.state == "654.321" + assert ( + active_tariff.attributes.get("unit_of_measurement") + == UnitOfEnergy.KILO_WATT_HOUR + ) + + async def test_belgian_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: @@ -533,7 +603,7 @@ async def test_belgian_meter( BELGIUM_CURRENT_AVERAGE_DEMAND, CosemObject( (0, 0), - [{"value": Decimal(1.75), "unit": "kW"}], + [{"value": Decimal("1.75"), "unit": "kW"}], ), "BELGIUM_CURRENT_AVERAGE_DEMAND", ) @@ -543,7 +613,7 @@ async def test_belgian_meter( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(4.11), "unit": "kW"}, + {"value": Decimal("4.11"), "unit": "kW"}, ], ), "BELGIUM_MAXIMUM_DEMAND_MONTH", @@ -567,7 +637,7 @@ async def test_belgian_meter( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal("745.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -591,7 +661,7 @@ async def test_belgian_meter( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(678.695), "unit": "m3"}, + {"value": Decimal("678.695"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -615,7 +685,7 @@ async def test_belgian_meter( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -639,7 +709,7 @@ async def test_belgian_meter( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -782,7 +852,7 @@ async def test_belgian_meter_alt( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(123.456), "unit": "m3"}, + {"value": Decimal("123.456"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -806,7 +876,7 @@ async def test_belgian_meter_alt( (0, 2), [ {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(678.901), "unit": "m3"}, + {"value": Decimal("678.901"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -830,7 +900,7 @@ async def test_belgian_meter_alt( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -854,7 +924,7 @@ async def test_belgian_meter_alt( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1001,7 +1071,7 @@ async def test_belgian_meter_mbus( (0, 3), [ {"value": datetime.datetime.fromtimestamp(1551642217)}, - {"value": Decimal(12.12), "unit": "m3"}, + {"value": Decimal("12.12"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1017,7 +1087,7 @@ async def test_belgian_meter_mbus( (0, 4), [ {"value": datetime.datetime.fromtimestamp(1551642218)}, - {"value": Decimal(13.13), "unit": "m3"}, + {"value": Decimal("13.13"), "unit": "m3"}, ], ), "MBUS_METER_READING", @@ -1154,7 +1224,7 @@ async def test_swedish_meter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1162,7 +1232,7 @@ async def test_swedish_meter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1229,7 +1299,7 @@ async def test_easymeter( ELECTRICITY_IMPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("54184.6316"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_IMPORTED_TOTAL", ) @@ -1237,7 +1307,7 @@ async def test_easymeter( ELECTRICITY_EXPORTED_TOTAL, CosemObject( (0, 0), - [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + [{"value": Decimal("19981.1069"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), "ELECTRICITY_EXPORTED_TOTAL", ) @@ -1489,7 +1559,7 @@ async def test_gas_meter_providing_energy_reading( (0, 0), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE}, + {"value": Decimal("123.456"), "unit": UnitOfEnergy.GIGA_JOULE}, ], ), "GAS_METER_READING", @@ -1549,7 +1619,7 @@ async def test_heat_meter_mbus( (0, 1), [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "GJ"}, + {"value": Decimal("745.695"), "unit": "GJ"}, ], ), "MBUS_METER_READING", diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr index d407fe2dc5b..0a46dd7f476 100644 --- a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'dsmr_reader', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py index 2ddd8395e78..86805fb456f 100644 --- a/tests/components/dsmr_reader/test_definitions.py +++ b/tests/components/dsmr_reader/test_definitions.py @@ -12,7 +12,7 @@ from homeassistant.components.dsmr_reader.sensor import DSMRSensor from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message @pytest.mark.parametrize( @@ -93,6 +93,7 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: ) sensor = DSMRSensor(description, config_entry) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) await sensor.async_added_to_hass() # Test dsmr version, if it's a digit diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py index ed4182f450f..f74ef43bf07 100644 --- a/tests/components/duke_energy/conftest.py +++ b/tests/components/duke_energy/conftest.py @@ -11,12 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index 4bf4eb53ad6..3335e12b2a2 100644 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest from voluptuous import MultipleInvalid -import homeassistant.components.dynalite.const as dynalite +from homeassistant.components.dynalite import const as dynalite from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/eafm/conftest.py b/tests/components/eafm/conftest.py index 3b060563a30..5dbdc98ad29 100644 --- a/tests/components/eafm/conftest.py +++ b/tests/components/eafm/conftest.py @@ -15,5 +15,5 @@ def mock_get_stations(): @pytest.fixture def mock_get_station(): """Mock aioeafm.get_station.""" - with patch("homeassistant.components.eafm.get_station") as patched: + with patch("homeassistant.components.eafm.coordinator.get_station") as patched: yield patched diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index add604167b9..11febb26669 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index e0e82d68863..87d85250780 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -67,7 +67,7 @@ "hasHeatPump": false, "humidity": "30" }, - "equipmentStatus": "fan", + "equipmentStatus": "fan,humidifier", "events": [ { "name": "Event1", diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 5c919ffab5c..9edb1d42331 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -2,15 +2,8 @@ from unittest.mock import patch -from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN -import pytest - from homeassistant.components.ecobee import config_flow -from homeassistant.components.ecobee.const import ( - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DOMAIN, -) +from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -35,7 +28,6 @@ async def test_user_step_without_user_input(hass: HomeAssistant) -> None: """Test expected result if user step is called.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM @@ -46,7 +38,6 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if pin request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -64,7 +55,6 @@ async def test_pin_request_fails(hass: HomeAssistant) -> None: """Test expected result if pin request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -81,7 +71,6 @@ async def test_token_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if token request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -105,7 +94,6 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: """Test expected result if token request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -120,99 +108,3 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: assert result["step_id"] == "authorize" assert result["errors"]["base"] == "token_request_failed" assert result["description_placeholders"] == {"pin": "test-pin"} - - -@pytest.mark.skip(reason="Flaky/slow") -async def test_import_flow_triggered_but_no_ecobee_conf(hass: HomeAssistant) -> None: - """Test expected result if import flow triggers but ecobee.conf doesn't exist.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with valid tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = True - mock_ecobee.api_key = "test-api-key" - mock_ecobee.refresh_token = "test-token" - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DOMAIN - assert result["data"] == { - CONF_API_KEY: "test-api-key", - CONF_REFRESH_TOKEN: "test-token", - } - - -async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with invalid data.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with stale tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = False - - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) diff --git a/tests/components/ecobee/test_humidifier.py b/tests/components/ecobee/test_humidifier.py index 696ca3d6c0d..6f20d38deaa 100644 --- a/tests/components/ecobee/test_humidifier.py +++ b/tests/components/ecobee/test_humidifier.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.ecobee.humidifier import MODE_MANUAL, MODE_OFF from homeassistant.components.humidifier import ( + ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, @@ -17,6 +18,7 @@ from homeassistant.components.humidifier import ( MODE_AUTO, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, + HumidifierAction, HumidifierDeviceClass, HumidifierEntityFeature, ) @@ -44,6 +46,7 @@ async def test_attributes(hass: HomeAssistant) -> None: state = hass.states.get(DEVICE_ID) assert state.state == STATE_ON + assert state.attributes[ATTR_ACTION] == HumidifierAction.HUMIDIFYING assert state.attributes[ATTR_CURRENT_HUMIDITY] == 15 assert state.attributes[ATTR_MIN_HUMIDITY] == DEFAULT_MIN_HUMIDITY assert state.attributes[ATTR_MAX_HUMIDITY] == DEFAULT_MAX_HUMIDITY diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py index 85bfff08bdf..8678cfd4d05 100644 --- a/tests/components/ecoforest/conftest.py +++ b/tests/components/ecoforest/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from pyecoforest.models.device import Alarm, Device, OperationMode, State import pytest -from homeassistant.components.ecoforest import DOMAIN +from homeassistant.components.ecoforest.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py index 2ef10c1bd41..2fc4356d1d8 100644 --- a/tests/components/econet/test_config_flow.py +++ b/tests/components/econet/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from pyeconet.api import EcoNetApiInterface from pyeconet.errors import InvalidCredentialsError, PyeconetError -from homeassistant.components.econet import DOMAIN +from homeassistant.components.econet.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr index 62b356e379d..59e2f5a24b7 100644 --- a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index f21d019a7b1..2c657080c12 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -466,6 +476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index 38c8a9a5ab9..f9540e06038 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, @@ -70,6 +72,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/ecovacs/snapshots/test_event.ambr b/tests/components/ecovacs/snapshots/test_event.ambr index 8f433560cd1..d29bf8dd57a 100644 --- a/tests/components/ecovacs/snapshots/test_event.ambr +++ b/tests/components/ecovacs/snapshots/test_event.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index 9113445cc31..e403c937394 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr index 29c710a5cb7..6367872c7f7 100644 --- a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr +++ b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -39,6 +40,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr index c80132784e1..952fa4556b0 100644 --- a/tests/components/ecovacs/snapshots/test_number.ambr +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index 125e7f0cee8..354afca1178 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 755fcda9e7d..c4e5a5b1966 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +157,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -201,6 +205,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -249,6 +254,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +302,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -347,6 +354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -394,6 +402,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +449,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -489,6 +499,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +550,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -593,6 +605,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -640,6 +653,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -686,6 +700,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -732,6 +747,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -779,6 +795,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -827,6 +844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -878,6 +896,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -925,6 +944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -972,6 +992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1039,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1065,6 +1087,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1112,6 +1135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1164,6 +1188,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1242,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1267,6 +1293,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1321,6 +1348,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1368,6 +1396,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1415,6 +1444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1461,6 +1491,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1507,6 +1538,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1554,6 +1586,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1602,6 +1635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1653,6 +1687,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1700,6 +1735,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1747,6 +1783,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1793,6 +1830,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1840,6 +1878,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1889,6 +1928,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1939,6 +1979,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1993,6 +2034,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2040,6 +2082,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2086,6 +2129,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr index 59e891bea5e..48aa9d8fc17 100644 --- a/tests/components/ecovacs/snapshots/test_switch.ambr +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 65e0b19ea02..3021db62e6f 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -161,8 +161,8 @@ async def test_disabled_by_default_buttons( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 2185ae4c9eb..13b73d853d5 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,6 +116,6 @@ async def test_all_entities_loaded( entities: int, ) -> None: """Test that all entities are loaded together.""" - assert ( - hass.states.async_entity_ids_count() == entities - ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + assert hass.states.async_entity_ids_count() == entities, ( + f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" + ) diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index a735863d40a..32bc8f90696 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -136,9 +136,9 @@ async def test_disabled_by_default_number_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 5bcd8385320..8222e9976d5 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -172,9 +172,9 @@ async def test_disabled_by_default_sensors( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index b14cafeaba4..040528debaa 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -214,8 +214,8 @@ async def test_disabled_by_default_switch_entities( for entity_id in entity_ids: assert not hass.states.get(entity_id) - assert ( - entry := entity_registry.async_get(entity_id) - ), f"Entity registry entry for {entity_id} is missing" + assert (entry := entity_registry.async_get(entity_id)), ( + f"Entity registry entry for {entity_id} is missing" + ) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index addaa1b9c48..49c18bab239 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index cdad628de6b..ae1bc74df90 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,12 +4,14 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, LightMode +from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode import pytest from homeassistant.components.eheimdigital.const import DOMAIN from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,13 +35,34 @@ def classic_led_ctrl_mock(): ) classic_led_ctrl_mock.name = "Mock classicLEDcontrol+e" classic_led_ctrl_mock.aquarium_name = "Mock Aquarium" + classic_led_ctrl_mock.sw_version = "1.0.0_1.0.0" classic_led_ctrl_mock.light_mode = LightMode.DAYCL_MODE classic_led_ctrl_mock.light_level = (10, 39) return classic_led_ctrl_mock @pytest.fixture -def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: +def heater_mock(): + """Mock a Heater device.""" + heater_mock = MagicMock(spec=EheimDigitalHeater) + heater_mock.mac_address = "00:00:00:00:00:02" + heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER + heater_mock.name = "Mock Heater" + heater_mock.aquarium_name = "Mock Aquarium" + heater_mock.sw_version = "1.0.0_1.0.0" + heater_mock.temperature_unit = HeaterUnit.CELSIUS + heater_mock.current_temperature = 24.2 + heater_mock.target_temperature = 25.5 + heater_mock.is_heating = True + heater_mock.is_active = True + heater_mock.operation_mode = HeaterMode.MANUAL + return heater_mock + + +@pytest.fixture +def eheimdigital_hub_mock( + classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock +) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( patch( @@ -52,7 +75,20 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo ), ): eheimdigital_hub_mock.return_value.devices = { - "00:00:00:00:00:01": classic_led_ctrl_mock + "00:00:00:00:00:01": classic_led_ctrl_mock, + "00:00:00:00:00:02": heater_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Initialize the integration.""" + + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", new=AsyncMock + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr new file mode 100644 index 00000000000..73c7cf638e8 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -0,0 +1,155 @@ +# serializer version: 1 +# name: test_dynamic_new_devices[climate.mock_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[climate.mock_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_setup_heater[climate.mock_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_heater[climate.mock_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/eheimdigital/snapshots/test_light.ambr b/tests/components/eheimdigital/snapshots/test_light.ambr index 8df4745997e..a8b454f416e 100644 --- a/tests/components/eheimdigital/snapshots/test_light.ambr +++ b/tests/components/eheimdigital/snapshots/test_light.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -76,6 +77,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -139,6 +141,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +205,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -265,6 +269,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py new file mode 100644 index 00000000000..4abc33e449e --- /dev/null +++ b/tests/components/eheimdigital/test_climate.py @@ -0,0 +1,261 @@ +"""Tests for the climate module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import ( + EheimDeviceType, + EheimDigitalClientError, + HeaterMode, + HeaterUnit, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.eheimdigital.const import ( + HEATER_BIO_MODE, + HEATER_SMART_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("heater_mock") +async def test_setup_heater( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test climate platform setup for heater.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock} + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("preset_mode", "heater_mode"), + [ + (PRESET_NONE, HeaterMode.MANUAL), + (HEATER_BIO_MODE, HeaterMode.BIO), + (HEATER_SMART_MODE, HeaterMode.SMART), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + preset_mode: str, + heater_mode: HeaterMode, +) -> None: + """Test setting a preset mode.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_operation_mode.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.assert_awaited_with(heater_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a preset mode.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_target_temperature.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.assert_awaited_with(26.0) + + +@pytest.mark.parametrize( + ("hvac_mode", "active"), [(HVACMode.AUTO, True), (HVACMode.OFF, False)] +) +async def test_set_hvac_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + active: bool, +) -> None: + """Test setting a preset mode.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_active.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.assert_awaited_with(active=active) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + heater_mock: MagicMock, +) -> None: + """Test the climate state update.""" + heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT + heater_mock.is_heating = False + heater_mock.operation_mode = HeaterMode.BIO + + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("climate.mock_heater")) + + assert state.attributes["hvac_action"] == HVACAction.IDLE + assert state.attributes["preset_mode"] == HEATER_BIO_MODE + + heater_mock.is_active = False + heater_mock.operation_mode = HeaterMode.SMART + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("climate.mock_heater")) + assert state.state == HVACMode.OFF + assert state.attributes["preset_mode"] == HEATER_SMART_MODE diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py index e75cf31eb98..4bfd45e9259 100644 --- a/tests/components/eheimdigital/test_config_flow.py +++ b/tests/components/eheimdigital/test_config_flow.py @@ -7,11 +7,11 @@ from aiohttp import ClientConnectionError import pytest from homeassistant.components.eheimdigital.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.1"), diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py index 211a8b3b6fd..c64997ee372 100644 --- a/tests/components/eheimdigital/test_init.py +++ b/tests/components/eheimdigital/test_init.py @@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from .conftest import init_integration + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -21,9 +23,8 @@ async def test_remove_device( ) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py index da224979c43..81b63218085 100644 --- a/tests/components/eheimdigital/test_light.py +++ b/tests/components/eheimdigital/test_light.py @@ -1,7 +1,7 @@ """Tests for the light module.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from eheimdigital.types import EheimDeviceType, LightMode @@ -26,6 +26,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util.color import value_to_brightness +from .conftest import init_integration + from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -51,7 +53,13 @@ async def test_setup_classic_led_ctrl( classic_led_ctrl_mock.tankconfig = tankconfig - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( @@ -75,7 +83,13 @@ async def test_dynamic_new_devices( eheimdigital_hub_mock.return_value.devices = {} - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) assert ( @@ -106,10 +120,8 @@ async def test_turn_off( classic_led_ctrl_mock: MagicMock, ) -> None: """Test turning off the light.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await mock_config_entry.runtime_data._async_device_found( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -143,10 +155,8 @@ async def test_turn_on_brightness( expected_dim_value: int, ) -> None: """Test turning on the light with different brightness values.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -173,12 +183,10 @@ async def test_turn_on_effect( classic_led_ctrl_mock: MagicMock, ) -> None: """Test turning on the light with an effect value.""" - mock_config_entry.add_to_hass(hass) - classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -204,10 +212,8 @@ async def test_state_update( classic_led_ctrl_mock: MagicMock, ) -> None: """Test the light state update.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -228,10 +234,8 @@ async def test_update_failed( freezer: FrozenDateTimeFactory, ) -> None: """Test an failed update.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) diff --git a/tests/components/eight_sleep/test_init.py b/tests/components/eight_sleep/test_init.py index 6b94ff31139..2a1845191d3 100644 --- a/tests/components/eight_sleep/test_init.py +++ b/tests/components/eight_sleep/test_init.py @@ -1,14 +1,18 @@ """Tests for the Eight Sleep integration.""" from homeassistant.components.eight_sleep import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry -async def test_mazda_repair_issue( +async def test_eight_sleep_repair_issue( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test the Eight Sleep configuration entry loading/unloading handles the repair.""" @@ -33,6 +37,28 @@ async def test_mazda_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_mazda_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py index 7f5e08a56b5..936557ac3bf 100644 --- a/tests/components/electric_kiwi/__init__.py +++ b/tests/components/electric_kiwi/__init__.py @@ -1 +1,13 @@ """Tests for the Electric Kiwi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Fixture for setting up the integration with args.""" + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 010efcb7b5f..cc967631be4 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -2,11 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from time import time from unittest.mock import AsyncMock, patch -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import ( + AccountSummary, + CustomerConnection, + Hop, + HopIntervals, + Service, + Session, +) import pytest from homeassistant.components.application_credentials import ( @@ -23,37 +30,55 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -type YieldFixture = Generator[AsyncMock] -type ComponentSetup = Callable[[], Awaitable[bool]] + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host: None) -> None: - """Request setup.""" - - -@pytest.fixture -def component_setup( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> ComponentSetup: - """Fixture for setting up the integration.""" - - async def _setup_func() -> bool: - assert await async_setup_component(hass, "application_credentials", {}) - await hass.async_block_till_done() - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, +def electrickiwi_api() -> Generator[AsyncMock]: + """Mock ek api and return values.""" + with ( + patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.electric_kiwi.config_flow.ElectricKiwiApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.customer_number = 123456 + client.electricity = Service( + identifier="00000000DDA", + service="electricity", + service_status="Y", + is_primary_service=True, ) - await hass.async_block_till_done() - config_entry.add_to_hass(hass) - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return _setup_func + client.get_active_session.return_value = Session.from_dict( + load_json_value_fixture("session.json", DOMAIN) + ) + client.get_hop_intervals.return_value = HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + client.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + client.get_account_summary.return_value = AccountSummary.from_dict( + load_json_value_fixture("account_summary.json", DOMAIN) + ) + client.get_connection_details.return_value = CustomerConnection.from_dict( + load_json_value_fixture("connection_details.json", DOMAIN) + ) + yield client @pytest.fixture(name="config_entry") @@ -63,7 +88,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "12345", + "id": "123456", "auth_implementation": DOMAIN, "token": { "refresh_token": "mock-refresh-token", @@ -74,6 +99,54 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, }, unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="config_entry2") +def mock_config_entry2(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123457", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="1234567", + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="migrated_config_entry") +def mock_migrated_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123456", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="123456", + version=1, + minor_version=2, ) @@ -87,35 +160,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="ek_auth") -def electric_kiwi_auth() -> YieldFixture: +def electric_kiwi_auth() -> Generator[AsyncMock]: """Patch access to electric kiwi access token.""" with patch( - "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + "homeassistant.components.electric_kiwi.api.ConfigEntryElectricKiwiAuth" ) as mock_auth: mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") yield mock_auth - - -@pytest.fixture(name="ek_api") -def ek_api() -> YieldFixture: - """Mock ek api and return values.""" - with patch( - "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True - ) as mock_ek_api: - mock_ek_api.return_value.customer_number = 123456 - mock_ek_api.return_value.connection_id = 123456 - mock_ek_api.return_value.set_active_session.return_value = None - mock_ek_api.return_value.get_hop_intervals.return_value = ( - HopIntervals.from_dict( - load_json_value_fixture("hop_intervals.json", DOMAIN) - ) - ) - mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( - load_json_value_fixture("get_hop.json", DOMAIN) - ) - mock_ek_api.return_value.get_account_balance.return_value = ( - AccountBalance.from_dict( - load_json_value_fixture("account_balance.json", DOMAIN) - ) - ) - yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json deleted file mode 100644 index 25bc57784ee..00000000000 --- a/tests/components/electric_kiwi/fixtures/account_balance.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "data": { - "connections": [ - { - "hop_percentage": "3.5", - "id": 3, - "running_balance": "184.09", - "start_date": "2020-10-04", - "unbilled_days": 15 - } - ], - "last_billed_amount": "-66.31", - "last_billed_date": "2020-10-03", - "next_billing_date": "2020-11-03", - "is_prepay": "N", - "summary": { - "credits": "0.0", - "electricity_used": "184.09", - "other_charges": "0.00", - "payments": "-220.0" - }, - "total_account_balance": "-102.22", - "total_billing_days": 30, - "total_running_balance": "184.09", - "type": "account_running_balance" - }, - "status": 1 -} diff --git a/tests/components/electric_kiwi/fixtures/account_summary.json b/tests/components/electric_kiwi/fixtures/account_summary.json new file mode 100644 index 00000000000..6a05d6a3fe7 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_summary.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "account_summary", + "total_running_balance": "184.09", + "total_account_balance": "-102.22", + "total_billing_days": 31, + "next_billing_date": "2025-02-19", + "service_names": ["power"], + "services": { + "power": { + "connections": [ + { + "id": 515363, + "running_balance": "12.98", + "unbilled_days": 5, + "hop_percentage": "11.2", + "start_date": "2025-01-19", + "service_label": "Power" + } + ] + } + }, + "date_to_pay": "", + "invoice_id": "", + "total_invoiced_charges": "", + "default_to_pay": "", + "invoice_exists": 1, + "display_date": "2025-01-19", + "last_billed_date": "2025-01-18", + "last_billed_amount": "-21.02", + "summary": { + "electricity_used": "12.98", + "other_charges": "0.00", + "payments": "0.00", + "credits": "0.00", + "mobile_charges": "0.00", + "broadband_charges": "0.00", + "addon_unbilled_charges": {} + }, + "is_prepay": "N" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/connection_details.json b/tests/components/electric_kiwi/fixtures/connection_details.json new file mode 100644 index 00000000000..5b446659aab --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/connection_details.json @@ -0,0 +1,73 @@ +{ + "data": { + "type": "connection", + "id": 515363, + "customer_id": 273941, + "customer_number": 34030646, + "icp_identifier": "00000000DDA", + "address": "", + "short_address": "", + "physical_address_unit": "", + "physical_address_number": "555", + "physical_address_street": "RACECOURSE ROAD", + "physical_address_suburb": "", + "physical_address_town": "Blah", + "physical_address_region": "Blah", + "physical_address_postcode": "0000", + "is_active": "Y", + "pricing_plan": { + "id": 51423, + "usage": "0.0000", + "fixed": "0.6000", + "usage_rate_inc_gst": "0.0000", + "supply_rate_inc_gst": "0.6900", + "plan_description": "MoveMaster Anytime Residential (Low User)", + "plan_type": "movemaster_tou", + "signup_price_plan_blurb": "Better rates every day during off-peak, and all day on weekends. Plus half price nights (11pm-7am) and our best solar buyback.", + "signup_price_plan_label": "MoveMaster", + "app_price_plan_label": "Your MoveMaster rates are...", + "solar_rate_excl_gst": "0.1250", + "solar_rate_incl_gst": "0.1438", + "pricing_type": "tou_plus", + "tou_plus": { + "fixed_rate_excl_gst": "0.6000", + "fixed_rate_incl_gst": "0.6900", + "interval_types": ["peak", "off_peak_shoulder", "off_peak_night"], + "peak": { + "price_excl_gst": "0.5390", + "price_incl_gst": "0.6199", + "display_text": { + "Weekdays": "7am-9am, 5pm-9pm" + }, + "tou_plus_label": "Peak" + }, + "off_peak_shoulder": { + "price_excl_gst": "0.3234", + "price_incl_gst": "0.3719", + "display_text": { + "Weekdays": "9am-5pm, 9pm-11pm", + "Weekends": "7am-11pm" + }, + "tou_plus_label": "Off-peak shoulder" + }, + "off_peak_night": { + "price_excl_gst": "0.2695", + "price_incl_gst": "0.3099", + "display_text": { + "Every day": "11pm-7am" + }, + "tou_plus_label": "Off-peak night" + } + } + }, + "hop": { + "start_time": "9:00 PM", + "end_time": "10:00 PM", + "interval_start": "43", + "interval_end": "44" + }, + "start_date": "2022-03-03", + "end_date": "", + "property_type": "residential" + } +} diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json index d29825391e9..2b126bfc017 100644 --- a/tests/components/electric_kiwi/fixtures/get_hop.json +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -1,16 +1,18 @@ { "data": { - "connection_id": "3", - "customer_number": 1000001, - "end": { - "end_time": "5:00 PM", - "interval": "34" - }, + "type": "hop_customer", + "customer_id": 123456, + "service_type": "electricity", + "connection_id": 515363, + "billing_id": 1247975, "start": { - "start_time": "4:00 PM", - "interval": "33" + "interval": "33", + "start_time": "4:00 PM" }, - "type": "hop_customer" + "end": { + "interval": "34", + "end_time": "5:00 PM" + } }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json index 15ecc174f13..860630b000a 100644 --- a/tests/components/electric_kiwi/fixtures/hop_intervals.json +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -1,249 +1,250 @@ { "data": { - "hop_duration": "60", "type": "hop_intervals", + "hop_duration": "60", "intervals": { "1": { - "active": 1, + "start_time": "12:00 AM", "end_time": "1:00 AM", - "start_time": "12:00 AM" + "active": 1 }, "2": { - "active": 1, + "start_time": "12:30 AM", "end_time": "1:30 AM", - "start_time": "12:30 AM" + "active": 1 }, "3": { - "active": 1, + "start_time": "1:00 AM", "end_time": "2:00 AM", - "start_time": "1:00 AM" + "active": 1 }, "4": { - "active": 1, + "start_time": "1:30 AM", "end_time": "2:30 AM", - "start_time": "1:30 AM" + "active": 1 }, "5": { - "active": 1, + "start_time": "2:00 AM", "end_time": "3:00 AM", - "start_time": "2:00 AM" + "active": 1 }, "6": { - "active": 1, + "start_time": "2:30 AM", "end_time": "3:30 AM", - "start_time": "2:30 AM" + "active": 1 }, "7": { - "active": 1, + "start_time": "3:00 AM", "end_time": "4:00 AM", - "start_time": "3:00 AM" + "active": 1 }, "8": { - "active": 1, + "start_time": "3:30 AM", "end_time": "4:30 AM", - "start_time": "3:30 AM" + "active": 1 }, "9": { - "active": 1, + "start_time": "4:00 AM", "end_time": "5:00 AM", - "start_time": "4:00 AM" + "active": 1 }, "10": { - "active": 1, + "start_time": "4:30 AM", "end_time": "5:30 AM", - "start_time": "4:30 AM" + "active": 1 }, "11": { - "active": 1, + "start_time": "5:00 AM", "end_time": "6:00 AM", - "start_time": "5:00 AM" + "active": 1 }, "12": { - "active": 1, + "start_time": "5:30 AM", "end_time": "6:30 AM", - "start_time": "5:30 AM" + "active": 1 }, "13": { - "active": 1, + "start_time": "6:00 AM", "end_time": "7:00 AM", - "start_time": "6:00 AM" + "active": 1 }, "14": { - "active": 1, + "start_time": "6:30 AM", "end_time": "7:30 AM", - "start_time": "6:30 AM" + "active": 0 }, "15": { - "active": 1, + "start_time": "7:00 AM", "end_time": "8:00 AM", - "start_time": "7:00 AM" + "active": 0 }, "16": { - "active": 1, + "start_time": "7:30 AM", "end_time": "8:30 AM", - "start_time": "7:30 AM" + "active": 0 }, "17": { - "active": 1, + "start_time": "8:00 AM", "end_time": "9:00 AM", - "start_time": "8:00 AM" + "active": 0 }, "18": { - "active": 1, + "start_time": "8:30 AM", "end_time": "9:30 AM", - "start_time": "8:30 AM" + "active": 0 }, "19": { - "active": 1, + "start_time": "9:00 AM", "end_time": "10:00 AM", - "start_time": "9:00 AM" + "active": 1 }, "20": { - "active": 1, + "start_time": "9:30 AM", "end_time": "10:30 AM", - "start_time": "9:30 AM" + "active": 1 }, "21": { - "active": 1, + "start_time": "10:00 AM", "end_time": "11:00 AM", - "start_time": "10:00 AM" + "active": 1 }, "22": { - "active": 1, + "start_time": "10:30 AM", "end_time": "11:30 AM", - "start_time": "10:30 AM" + "active": 1 }, "23": { - "active": 1, + "start_time": "11:00 AM", "end_time": "12:00 PM", - "start_time": "11:00 AM" + "active": 1 }, "24": { - "active": 1, + "start_time": "11:30 AM", "end_time": "12:30 PM", - "start_time": "11:30 AM" + "active": 1 }, "25": { - "active": 1, + "start_time": "12:00 PM", "end_time": "1:00 PM", - "start_time": "12:00 PM" + "active": 1 }, "26": { - "active": 1, + "start_time": "12:30 PM", "end_time": "1:30 PM", - "start_time": "12:30 PM" + "active": 1 }, "27": { - "active": 1, + "start_time": "1:00 PM", "end_time": "2:00 PM", - "start_time": "1:00 PM" + "active": 1 }, "28": { - "active": 1, + "start_time": "1:30 PM", "end_time": "2:30 PM", - "start_time": "1:30 PM" + "active": 1 }, "29": { - "active": 1, + "start_time": "2:00 PM", "end_time": "3:00 PM", - "start_time": "2:00 PM" + "active": 1 }, "30": { - "active": 1, + "start_time": "2:30 PM", "end_time": "3:30 PM", - "start_time": "2:30 PM" + "active": 1 }, "31": { - "active": 1, + "start_time": "3:00 PM", "end_time": "4:00 PM", - "start_time": "3:00 PM" + "active": 1 }, "32": { - "active": 1, + "start_time": "3:30 PM", "end_time": "4:30 PM", - "start_time": "3:30 PM" + "active": 1 }, "33": { - "active": 1, + "start_time": "4:00 PM", "end_time": "5:00 PM", - "start_time": "4:00 PM" + "active": 1 }, "34": { - "active": 1, + "start_time": "4:30 PM", "end_time": "5:30 PM", - "start_time": "4:30 PM" + "active": 0 }, "35": { - "active": 1, + "start_time": "5:00 PM", "end_time": "6:00 PM", - "start_time": "5:00 PM" + "active": 0 }, "36": { - "active": 1, + "start_time": "5:30 PM", "end_time": "6:30 PM", - "start_time": "5:30 PM" + "active": 0 }, "37": { - "active": 1, + "start_time": "6:00 PM", "end_time": "7:00 PM", - "start_time": "6:00 PM" + "active": 0 }, "38": { - "active": 1, + "start_time": "6:30 PM", "end_time": "7:30 PM", - "start_time": "6:30 PM" + "active": 0 }, "39": { - "active": 1, + "start_time": "7:00 PM", "end_time": "8:00 PM", - "start_time": "7:00 PM" + "active": 0 }, "40": { - "active": 1, + "start_time": "7:30 PM", "end_time": "8:30 PM", - "start_time": "7:30 PM" + "active": 0 }, "41": { - "active": 1, + "start_time": "8:00 PM", "end_time": "9:00 PM", - "start_time": "8:00 PM" + "active": 0 }, "42": { - "active": 1, + "start_time": "8:30 PM", "end_time": "9:30 PM", - "start_time": "8:30 PM" + "active": 0 }, "43": { - "active": 1, + "start_time": "9:00 PM", "end_time": "10:00 PM", - "start_time": "9:00 PM" + "active": 1 }, "44": { - "active": 1, + "start_time": "9:30 PM", "end_time": "10:30 PM", - "start_time": "9:30 PM" + "active": 1 }, "45": { - "active": 1, - "end_time": "11:00 AM", - "start_time": "10:00 PM" + "start_time": "10:00 PM", + "end_time": "11:00 PM", + "active": 1 }, "46": { - "active": 1, + "start_time": "10:30 PM", "end_time": "11:30 PM", - "start_time": "10:30 PM" + "active": 1 }, "47": { - "active": 1, + "start_time": "11:00 PM", "end_time": "12:00 AM", - "start_time": "11:00 PM" + "active": 1 }, "48": { - "active": 1, + "start_time": "11:30 PM", "end_time": "12:30 AM", - "start_time": "11:30 PM" + "active": 0 } - } + }, + "service_type": "electricity" }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/session.json b/tests/components/electric_kiwi/fixtures/session.json new file mode 100644 index 00000000000..ee04aaca549 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session.json @@ -0,0 +1,23 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [ + { + "service": "Electricity", + "identifier": "00000000DDA", + "is_primary_service": true, + "service_status": "Y" + } + ], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/session_no_services.json b/tests/components/electric_kiwi/fixtures/session_no_services.json new file mode 100644 index 00000000000..62ae7aea20a --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session_no_services.json @@ -0,0 +1,16 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 681320972b5..ab643a0ddf1 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -3,70 +3,40 @@ from __future__ import annotations from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from electrickiwi_api.exceptions import ApiException import pytest -from homeassistant import config_entries -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.electric_kiwi.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, SCOPE_VALUES, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component -from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +from .conftest import CLIENT_ID, REDIRECT_URI from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup application credentials component.""" - await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: - """Test config flow base case with no credentials registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "electrickiwi_api") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" - await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) - ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -76,13 +46,13 @@ async def test_full_flow( }, ) - URL_SCOPE = SCOPE_VALUES.replace(" ", "+") + url_scope = SCOPE_VALUES.replace(" ", "+") assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}" f"&state={state}" - f"&scope={URL_SCOPE}" + f"&scope={url_scope}" ) client = await hass_client_no_auth() @@ -90,6 +60,7 @@ async def test_full_flow( assert resp.status == HTTPStatus.OK assert resp.headers["content-type"] == "text/html; charset=utf-8" + aioclient_mock.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -106,20 +77,73 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + electrickiwi_api: AsyncMock, +) -> None: + """Check failure on creation of entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + url_scope = SCOPE_VALUES.replace(" ", "+") + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + f"&scope={url_scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + electrickiwi_api.get_active_session.side_effect = ApiException() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "connection_error" + + @pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - config_entry: MockConfigEntry, + migrated_config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - config_entry.add_to_hass(hass) + migrated_config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER, "entry_id": DOMAIN} ) state = config_entry_oauth2_flow._encode_jwt( @@ -145,7 +169,9 @@ async def test_existing_entry( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -154,13 +180,13 @@ async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, - config_entry: MockConfigEntry, - setup_credentials: None, + mock_setup_entry: AsyncMock, + migrated_config_entry: MockConfigEntry, ) -> None: """Test Electric Kiwi reauthentication.""" - config_entry.add_to_hass(hass) - result = await config_entry.start_reauth_flow(hass) + migrated_config_entry.add_to_hass(hass) + + result = await migrated_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -189,8 +215,11 @@ async def test_reauthentication( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" diff --git a/tests/components/electric_kiwi/test_init.py b/tests/components/electric_kiwi/test_init.py new file mode 100644 index 00000000000..947f788ad55 --- /dev/null +++ b/tests/components/electric_kiwi/test_init.py @@ -0,0 +1,135 @@ +"""Test the Electric Kiwi init.""" + +import http +from unittest.mock import AsyncMock, patch + +from aiohttp import RequestInfo +from aiohttp.client_exceptions import ClientResponseError +from electrickiwi_api.exceptions import ApiException, AuthException +import pytest + +from homeassistant.components.electric_kiwi.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + await init_integration(hass, config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_multiple_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, + config_entry2: MockConfigEntry, +) -> None: + """Test a successful setup and unload of multiple entries.""" + + for entry in (config_entry, config_entry2): + await init_integration(hass, entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + for entry in (config_entry, config_entry2): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + ( + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_refresh_token_validity_failures( + hass: HomeAssistant, + config_entry: MockConfigEntry, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test token refresh failure status.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + RequestInfo("", "POST", {}, ""), None, status=status + ), + ) as mock_async_ensure_token_valid: + await init_integration(hass, config_entry) + mock_async_ensure_token_valid.assert_called_once() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_unique_id_migration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID is migrated to the customer number.""" + + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, "123456_515363_sensor", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + new_entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert new_entry.minor_version == 2 + assert new_entry.unique_id == "123456" + entity_entry = entity_registry.async_get( + "sensor.electric_kiwi_123456_515363_sensor" + ) + assert entity_entry.unique_id == "123456_00000000DDA_sensor" + + +async def test_unique_id_migration_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = ApiException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_unique_id_migration_auth_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = AuthException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index bb3304ec66c..3e58b33a998 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -18,9 +18,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from .conftest import ComponentSetup, YieldFixture +from . import init_integration from tests.common import MockConfigEntry @@ -47,10 +47,9 @@ def restore_timezone(): async def test_hop_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: Mock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, ) -> None: @@ -61,7 +60,7 @@ async def test_hop_sensors( sensor state should be set to today at 4pm or if now is past 4pm, then tomorrow at 4pm. """ - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -70,8 +69,7 @@ async def test_hop_sensors( state = hass.states.get(sensor) assert state - api = ek_api(Mock()) - hop_data = await api.get_hop() + hop_data = await electrickiwi_api.get_hop() value = _check_and_move_time(hop_data, sensor_state) @@ -98,20 +96,19 @@ async def test_hop_sensors( ), ( "sensor.next_billing_date", - "2020-11-03T00:00:00", + "2025-02-19T00:00:00", SensorDeviceClass.DATE, None, ), - ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ("sensor.hour_of_power_savings", "11.2", None, SensorStateClass.MEASUREMENT), ], ) async def test_account_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: AsyncMock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, device_class: str, @@ -119,7 +116,7 @@ async def test_account_sensors( ) -> None: """Test Account sensors for the Electric Kiwi integration.""" - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -133,9 +130,9 @@ async def test_account_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == state_class -async def test_check_and_move_time(ek_api: AsyncMock) -> None: +async def test_check_and_move_time(electrickiwi_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" - hop = await ek_api(Mock()).get_hop() + hop = await electrickiwi_api.get_hop() test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) dt_util.set_default_time_zone(TEST_TIMEZONE) diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index 7151aab10f2..a63672cc85d 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -13,6 +13,7 @@ import pytest from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( + ATTR_MODEL, CONF_MODEL, CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, @@ -163,6 +164,16 @@ async def mock_config_entry_setup( @pytest.mark.parametrize( ("setup", "tts_service", "service_data"), [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), ( "mock_config_entry_setup", "speak", @@ -173,6 +184,26 @@ async def mock_config_entry_setup( tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, }, ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), ], indirect=["setup"], ) @@ -206,11 +237,13 @@ async def test_tts_service_speak( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == HTTPStatus.OK ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") tts_entity._client.generate.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice=voice_id, + model=model_id, voice_settings=tts_entity._voice_settings, optimize_streaming_latency=tts_entity._latency, ) @@ -317,7 +350,7 @@ async def test_tts_service_speak_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) tts_entity._client.generate.assert_called_once_with( diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index dcf9d1c87d0..81a817f2738 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +136,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 4bb4644ab86..84f7ca45843 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -52,6 +52,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -82,6 +83,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -169,6 +171,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +202,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -286,6 +290,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -316,6 +321,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index be0ec0a56c5..f64893798e9 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -114,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -207,6 +211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -243,6 +248,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -300,6 +306,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -390,6 +398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -426,6 +435,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index ba95160d28a..254e4deb7d9 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +134,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 00763f60458..c647d36902a 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -57,7 +57,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", @@ -141,7 +141,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -181,7 +181,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -202,7 +202,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.2"), ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", @@ -230,7 +230,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index e56bb5f4699..5355013bf94 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -7,12 +7,12 @@ from elkm1_lib.discovery import ElkSystem import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.elkm1.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( ELK_DISCOVERY, @@ -27,7 +27,7 @@ from . import ( from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( MOCK_IP_ADDRESS, "", dr.format_mac(MOCK_MAC).replace(":", "") ) ELK_DISCOVERY_INFO = asdict(ELK_DISCOVERY) @@ -1141,7 +1141,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=MOCK_IP_ADDRESS, macaddress="00:00:00:00:00:00", diff --git a/tests/components/elmax/__init__.py b/tests/components/elmax/__init__.py index e1a6728f1f5..391c3ccbfb2 100644 --- a/tests/components/elmax/__init__.py +++ b/tests/components/elmax/__init__.py @@ -30,6 +30,7 @@ MOCK_PANEL_PIN = "000000" MOCK_WRONG_PANEL_PIN = "000000" MOCK_PASSWORD = "password" MOCK_DIRECT_HOST = "1.1.1.1" +MOCK_DIRECT_HOST_V6 = "fd00::be2:54:34:2" MOCK_DIRECT_HOST_CHANGED = "2.2.2.2" MOCK_DIRECT_PORT = 443 MOCK_DIRECT_SSL = True diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index f8cf33ffe1a..02f01036996 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -18,6 +18,7 @@ import respx from . import ( MOCK_DIRECT_HOST, + MOCK_DIRECT_HOST_V6, MOCK_DIRECT_PORT, MOCK_DIRECT_SSL, MOCK_PANEL_ID, @@ -29,6 +30,7 @@ from tests.common import load_fixture MOCK_DIRECT_BASE_URI = ( f"{'https' if MOCK_DIRECT_SSL else 'http'}://{MOCK_DIRECT_HOST}:{MOCK_DIRECT_PORT}" ) +MOCK_DIRECT_BASE_URI_V6 = f"{'https' if MOCK_DIRECT_SSL else 'http'}://[{MOCK_DIRECT_HOST_V6}]:{MOCK_DIRECT_PORT}" @pytest.fixture(autouse=True) @@ -58,12 +60,16 @@ def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]: yield respx_mock +@pytest.fixture +def base_uri() -> str: + """Configure the base-uri for the respx mock fixtures.""" + return MOCK_DIRECT_BASE_URI + + @pytest.fixture(autouse=True) -def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]: +def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]: """Configure httpx fixture for direct Panel-API communication.""" - with respx.mock( - base_url=MOCK_DIRECT_BASE_URI, assert_all_called=False - ) as respx_mock: + with respx.mock(base_url=base_uri, assert_all_called=False) as respx_mock: # Mock Login POST. login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login") diff --git a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr index f175fc707bb..2bf3aa48430 100644 --- a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/snapshots/test_binary_sensor.ambr b/tests/components/elmax/snapshots/test_binary_sensor.ambr index 3c3f63b44ca..7515547406e 100644 --- a/tests/components/elmax/snapshots/test_binary_sensor.ambr +++ b/tests/components/elmax/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/snapshots/test_cover.ambr b/tests/components/elmax/snapshots/test_cover.ambr index 0dbea416934..8cb230e1523 100644 --- a/tests/components/elmax/snapshots/test_cover.ambr +++ b/tests/components/elmax/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/snapshots/test_switch.ambr b/tests/components/elmax/snapshots/test_switch.ambr index 0ae1942e7e0..f5845223717 100644 --- a/tests/components/elmax/snapshots/test_switch.ambr +++ b/tests/components/elmax/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py index 76dc8845662..88fc0a33c51 100644 --- a/tests/components/elmax/test_alarm_control_panel.py +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from syrupy import SnapshotAssertion -from homeassistant.components.elmax import POLLING_SECONDS +from homeassistant.components.elmax.const import POLLING_SECONDS from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index 7a4d9755fa5..379cfa98bbc 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -1,11 +1,12 @@ """Tests for the Elmax config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import patch from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError +import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.elmax.const import ( CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD, @@ -23,11 +24,13 @@ from homeassistant.components.elmax.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( MOCK_DIRECT_CERT, MOCK_DIRECT_HOST, MOCK_DIRECT_HOST_CHANGED, + MOCK_DIRECT_HOST_V6, MOCK_DIRECT_PORT, MOCK_DIRECT_SSL, MOCK_PANEL_ID, @@ -37,12 +40,13 @@ from . import ( MOCK_USERNAME, MOCK_WRONG_PANEL_PIN, ) +from .conftest import MOCK_DIRECT_BASE_URI_V6 from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST, - ip_addresses=[MOCK_DIRECT_HOST], +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( + ip_address=IPv4Address(address=MOCK_DIRECT_HOST), + ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -54,9 +58,9 @@ MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST_CHANGED, - ip_addresses=[MOCK_DIRECT_HOST_CHANGED], +MOCK_ZEROCONF_DISCOVERY_INFO_V6 = ZeroconfServiceInfo( + ip_address=IPv6Address(address=MOCK_DIRECT_HOST_V6), + ip_addresses=[IPv6Address(address=MOCK_DIRECT_HOST_V6)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -68,9 +72,23 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = zeroconf.ZeroconfServiceInfo( }, type="_elmax-ssl._tcp", ) -MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = zeroconf.ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST, - ip_addresses=[MOCK_DIRECT_HOST], +MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( + ip_address=IPv4Address(address=MOCK_DIRECT_HOST_CHANGED), + ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST_CHANGED)], + hostname="VideoBox.local", + name="VideoBox", + port=443, + properties={ + "idl": MOCK_PANEL_ID, + "idr": MOCK_PANEL_ID, + "v1": "PHANTOM64PRO_GSM 11.9.844", + "v2": "4.9.13", + }, + type="_elmax-ssl._tcp", +) +MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_DIRECT_HOST), + ip_addresses=[IPv4Address(MOCK_DIRECT_HOST)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -194,6 +212,18 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: assert result["errors"] is None +async def test_zeroconf_discovery_ipv6(hass: HomeAssistant) -> None: + """Test discovery of Elmax local api panel.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO_V6, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_setup" + assert result["errors"] is None + + async def test_zeroconf_setup_show_form(hass: HomeAssistant) -> None: """Test discovery shows a form when activated.""" result = await hass.config_entries.flow.async_init( @@ -230,6 +260,27 @@ async def test_zeroconf_setup(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.parametrize("base_uri", [MOCK_DIRECT_BASE_URI_V6]) +async def test_zeroconf_ipv6_setup(hass: HomeAssistant) -> None: + """Test the successful creation of config entry via discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO_V6, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_MODE_DIRECT_SSL: MOCK_DIRECT_SSL, + }, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Ensure local discovery aborts when same panel is already added to ha.""" MockConfigEntry( diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 210196ce414..6dc19155863 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/emonitor/test_config_flow.py b/tests/components/emonitor/test_config_flow.py index e77ebcc08b0..3e5f4004d1a 100644 --- a/tests/components/emonitor/test_config_flow.py +++ b/tests/components/emonitor/test_config_flow.py @@ -6,15 +6,15 @@ from aioemonitor.monitor import EmonitorNetwork, EmonitorStatus import aiohttp from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.emonitor.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="emonitor", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8a340d5e2dd..97dcc782096 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -58,7 +58,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType from tests.common import ( diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py index c142e436fd3..d0301034cf8 100644 --- a/tests/components/energenie_power_sockets/conftest.py +++ b/tests/components/energenie_power_sockets/conftest.py @@ -44,7 +44,7 @@ def get_pyegps_device_mock() -> MagicMock: fkObj = FakePowerStrip( devId=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], number_of_sockets=4 ) - fkObj.release = lambda: True + fkObj.release = lambda: None fkObj._status = [0, 1, 0, 1] usb_device_mock = MagicMock(wraps=fkObj) diff --git a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr index d462d6ca6d4..99595168157 100644 --- a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr +++ b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/energenie_power_sockets/test_init.py b/tests/components/energenie_power_sockets/test_init.py index 4e2fe51665b..a11cef319b2 100644 --- a/tests/components/energenie_power_sockets/test_init.py +++ b/tests/components/energenie_power_sockets/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock from pyegps.exceptions import UsbError -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,13 +23,11 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert DOMAIN not in hass.data async def test_device_not_found_on_load_entry( diff --git a/tests/components/energenie_power_sockets/test_switch.py b/tests/components/energenie_power_sockets/test_switch.py index 4cd2bd60028..27f13390a83 100644 --- a/tests/components/energenie_power_sockets/test_switch.py +++ b/tests/components/energenie_power_sockets/test_switch.py @@ -6,7 +6,6 @@ from pyegps.exceptions import EgpsException import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOME_ASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -118,7 +117,6 @@ async def test_switch_setup( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] state = hass.states.get(f"switch.{entity_name}") assert state == snapshot diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a27451b853d..a438842f8a5 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/energyzero/__init__.py b/tests/components/energyzero/__init__.py index 287bdf6a2f4..35a1346790f 100644 --- a/tests/components/energyzero/__init__.py +++ b/tests/components/energyzero/__init__.py @@ -1 +1,12 @@ """Tests for the EnergyZero integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index d42283c0d4b..3fd93ee31f8 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -29,7 +29,8 @@ def mock_config_entry() -> MockConfigEntry: title="energy", domain=DOMAIN, data={}, - unique_id="unique_thingy", + unique_id=DOMAIN, + entry_id="12345", ) diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr deleted file mode 100644 index 72e504c97c8..00000000000 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,39 +0,0 @@ -# serializer version: 1 -# name: test_full_user_flow - FlowResultSnapshot({ - 'context': dict({ - 'source': 'user', - 'unique_id': 'energyzero', - }), - 'data': dict({ - }), - 'description': None, - 'description_placeholders': None, - 'flow_id': , - 'handler': 'energyzero', - 'minor_version': 1, - 'options': dict({ - }), - 'result': ConfigEntrySnapshot({ - 'data': dict({ - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'energyzero', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'title': 'EnergyZero', - 'unique_id': 'energyzero', - 'version': 1, - }), - 'title': 'EnergyZero', - 'type': , - 'version': 1, - }) -# --- diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 90c11ecfc6f..aaa52cfeb7e 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -1,26 +1,4 @@ # serializer version: 1 -# name: test_diagnostics - dict({ - 'energy': dict({ - 'average_price': 0.37, - 'current_hour_price': 0.49, - 'highest_price_time': '2022-12-07T16:00:00+00:00', - 'hours_priced_equal_or_lower': 23, - 'lowest_price_time': '2022-12-07T02:00:00+00:00', - 'max_price': 0.55, - 'min_price': 0.26, - 'next_hour_price': 0.55, - 'percentage_of_max': 89.09, - }), - 'entry': dict({ - 'title': 'energy', - }), - 'gas': dict({ - 'current_hour_price': 1.47, - 'next_hour_price': 1.47, - }), - }) -# --- # name: test_diagnostics_no_gas_today dict({ 'energy': dict({ @@ -43,3 +21,25 @@ }), }) # --- +# name: test_entry_diagnostics + dict({ + 'energy': dict({ + 'average_price': 0.37, + 'current_hour_price': 0.49, + 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, + 'lowest_price_time': '2022-12-07T02:00:00+00:00', + 'max_price': 0.55, + 'min_price': 0.26, + 'next_hour_price': 0.55, + 'percentage_of_max': 89.09, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': 1.47, + 'next_hour_price': 1.47, + }), + }) +# --- diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 3a66f25fd32..5407ac8f0e9 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -1,26 +1,12 @@ # serializer version: 1 -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Average - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.37', - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_average_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -43,52 +29,26 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'average_price', + 'unique_id': '12345_today_energy_average_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_average_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , + 'friendly_name': 'Energy market price Average - today', 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_average_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.49', + 'state': '0.37', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -97,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -119,57 +80,34 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_energy_current_hour_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'device_class': 'timestamp', - 'friendly_name': 'Energy market price Time of highest price - today', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2022-12-07T16:00:00+00:00', + 'state': '0.49', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,57 +130,33 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'highest_price_time', + 'unique_id': '12345_today_energy_highest_price_time', 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of highest price - today', }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '23', + 'state': '2022-12-07T16:00:00+00:00', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -265,57 +179,82 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hours_priced_equal_or_lower', + 'unique_id': '12345_today_energy_hours_priced_equal_or_lower', 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Highest price - today', - 'unit_of_measurement': '€/kWh', + 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.55', + 'state': '23', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time of lowest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lowest_price_time', + 'unique_id': '12345_today_energy_lowest_price_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_lowest_price_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of lowest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_lowest_price_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-07T02:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -338,52 +277,173 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'max_price', + 'unique_id': '12345_today_energy_max_price', 'unit_of_measurement': '€/kWh', }) # --- -# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas] +# name: test_sensor[sensor.energyzero_today_energy_max_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Gas market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/m³', + 'friendly_name': 'Energy market price Highest price - today', + 'unit_of_measurement': '€/kWh', }), 'context': , - 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'entity_id': 'sensor.energyzero_today_energy_max_price', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.47', + 'state': '0.55', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].1 +# name: test_sensor[sensor.energyzero_today_energy_min_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_min_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lowest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'min_price', + 'unique_id': '12345_today_energy_min_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_min_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Lowest price - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_min_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.26', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_energy_next_hour_price', + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Next hour', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.55', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current percentage of highest price - today', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'percentage_of_max', + 'unique_id': '12345_today_energy_percentage_of_max', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current percentage of highest price - today', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_percentage_of_max', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89.09', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -392,6 +452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -414,32 +475,72 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', + 'unique_id': '12345_today_gas_current_hour_price', 'unit_of_measurement': '€/m³', }) # --- -# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/m³', }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'EnergyZero', - 'model': None, - 'model_id': None, - 'name': 'Gas market price', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next hour', + 'platform': 'energyzero', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_hour_price', + 'unique_id': '12345_today_gas_next_hour_price', + 'unit_of_measurement': '€/m³', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_next_hour_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Next hour', + 'unit_of_measurement': '€/m³', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_next_hour_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.47', }) # --- diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index 4c4e831e448..09884ff4cf6 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import MagicMock -from syrupy.assertion import SnapshotAssertion - from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -15,7 +13,6 @@ from tests.common import MockConfigEntry async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, - snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -32,7 +29,8 @@ async def test_full_user_flow( ) assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2 == snapshot + assert result2.get("title") == "EnergyZero" + assert result2.get("data") == {} assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/energyzero/test_diagnostics.py b/tests/components/energyzero/test_diagnostics.py index f4408ded05d..198f21822c7 100644 --- a/tests/components/energyzero/test_diagnostics.py +++ b/tests/components/energyzero/test_diagnostics.py @@ -1,53 +1,54 @@ """Tests for the diagnostics data provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.energyzero.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_diagnostics( +async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - init_integration: MockConfigEntry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test diagnostics.""" - assert ( - await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - == snapshot + """Test the EnergyZero entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry ) + assert result == snapshot + async def test_diagnostics_no_gas_today( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_energyzero: MagicMock, init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 5c4700c21f1..d952ac77515 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -1,96 +1,49 @@ """Tests for the sensors provided by the EnergyZero integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from energyzero import EnergyZeroNoDataError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props -from homeassistant.components.energyzero.const import DOMAIN -from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.energyzero.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] -@pytest.mark.parametrize( - ("entity_id", "entity_unique_id", "device_identifier"), - [ - ( - "sensor.energyzero_today_energy_current_hour_price", - "today_energy_current_hour_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_average_price", - "today_energy_average_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_max_price", - "today_energy_max_price", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_highest_price_time", - "today_energy_highest_price_time", - "today_energy", - ), - ( - "sensor.energyzero_today_energy_hours_priced_equal_or_lower", - "today_energy_hours_priced_equal_or_lower", - "today_energy", - ), - ( - "sensor.energyzero_today_gas_current_hour_price", - "today_gas_current_hour_price", - "today_gas", - ), - ], -) async def test_sensor( hass: HomeAssistant, - init_integration: MockConfigEntry, - device_registry: dr.DeviceRegistry, + mock_energyzero: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - entity_id: str, - entity_unique_id: str, - device_identifier: str, ) -> None: """Test the EnergyZero - Energy sensors.""" - entry_id = init_integration.entry_id - assert (state := hass.states.get(entity_id)) - assert state == snapshot - assert (entity_entry := entity_registry.async_get(entity_id)) - assert entity_entry == snapshot(exclude=props("unique_id")) - assert entity_entry.unique_id == f"{entry_id}_{entity_unique_id}" + with patch("homeassistant.components.energyzero.PLATFORMS", ["sensor"]): + await setup_integration(hass, mock_config_entry) - assert entity_entry.device_id - assert (device_entry := device_registry.async_get(entity_entry.device_id)) - assert device_entry == snapshot(exclude=props("identifiers")) - assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_{device_identifier}")} + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("init_integration") -async def test_no_gas_today(hass: HomeAssistant, mock_energyzero: MagicMock) -> None: +async def test_no_gas_today( + hass: HomeAssistant, + mock_energyzero: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: """Test the EnergyZero - No gas sensors available.""" - await async_setup_component(hass, "homeassistant", {}) - mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError - await hass.services.async_call( - "homeassistant", - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, - blocking=True, - ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("sensor.energyzero_today_gas_current_hour_price")) diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py index a53d1494e9a..a16ef69979b 100644 --- a/tests/components/enigma2/conftest.py +++ b/tests/components/enigma2/conftest.py @@ -1,6 +1,10 @@ """Test the Enigma2 config flow.""" -from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from openwebif.api import OpenWebIfDevice, OpenWebIfServiceEvent, OpenWebIfStatus +import pytest from homeassistant.components.enigma2.const import ( CONF_DEEP_STANDBY, @@ -10,6 +14,7 @@ from homeassistant.components.enigma2.const import ( DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, + DOMAIN, ) from homeassistant.const import ( CONF_HOST, @@ -20,6 +25,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) +from tests.common import MockConfigEntry, load_json_object_fixture + MAC_ADDRESS = "12:34:56:78:90:ab" TEST_REQUIRED = { @@ -45,42 +52,41 @@ EXPECTED_OPTIONS = { } -class MockDevice: - """A mock Enigma2 device.""" +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, unique_id="12:34:56:78:90:ab" + ) - mac_address: str | None = "12:34:56:78:90:ab" - _base = "http://1.1.1.1" - def __init__(self) -> None: - """Initialize the mock Enigma2 device.""" - self.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) +@pytest.fixture +def openwebif_device_mock() -> Generator[AsyncMock]: + """Mock a OpenWebIf device.""" - async def _call_api(self, url: str) -> dict | None: - if url.endswith("/api/about"): - return { - "info": { - "ifaces": [ - { - "mac": self.mac_address, - } - ], - "model": "Mock Enigma2", - "brand": "Enigma2", - } - } - return None - - def get_version(self) -> str | None: - """Return the version.""" - return None - - async def get_about(self) -> dict: - """Get mock about endpoint.""" - return await self._call_api("/api/about") - - async def get_all_bouquets(self) -> dict: - """Get all bouquets.""" - return { + with ( + patch( + "homeassistant.components.enigma2.coordinator.OpenWebIfDevice", + spec=OpenWebIfDevice, + ) as openwebif_device_mock, + patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice", + new=openwebif_device_mock, + ), + ): + device = openwebif_device_mock.return_value + device.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent()) + device.turn_off_to_deep = False + device.sources = {"Test": "1"} + device.source_list = list(device.sources.keys()) + device.picon_url = "file:///" + device.get_about.return_value = load_json_object_fixture( + "device_about.json", DOMAIN + ) + device.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_on.json", DOMAIN + ) + device.get_all_bouquets.return_value = { "bouquets": [ [ '1:7:1:0:0:0:0:0:0:0:FROM BOUQUET "userbouquet.favourites.tv" ORDER BY bouquet', @@ -88,9 +94,4 @@ class MockDevice: ] ] } - - async def update(self) -> None: - """Mock update.""" - - async def close(self): - """Mock close.""" + yield device diff --git a/tests/components/enigma2/fixtures/device_about.json b/tests/components/enigma2/fixtures/device_about.json new file mode 100644 index 00000000000..5b992fa1bd5 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": "12:34:56:78:90:ab", + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_about_without_mac.json b/tests/components/enigma2/fixtures/device_about_without_mac.json new file mode 100644 index 00000000000..02a84edcac2 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_about_without_mac.json @@ -0,0 +1,158 @@ +{ + "info": { + "brand": "GigaBlue", + "model": "UHD QUAD 4K", + "boxtype": "gbquad4k", + "machinebuild": "gb7252", + "lcd": 1, + "grabpip": 1, + "chipset": "bcm7252s", + "mem1": "906132 kB", + "mem2": "616396 kB", + "mem3": "616396 kB frei / 906132 kB insgesamt", + "uptime": "46d 15:47", + "webifver": "OWIF 2.2.0", + "imagedistro": "openatv", + "friendlyimagedistro": "openATV", + "oever": "OE-Alliance 5.5", + "imagever": "7.5.20241101", + "enigmaver": "2024-10-31", + "driverdate": "20200723", + "kernelver": "4.1.20", + "fp_version": 0, + "friendlychipsetdescription": "Chipsatz", + "friendlychipsettext": "Broadcom 7252s", + "tuners": [ + { + "name": "Tuner A", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner B", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner C", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner D", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner E", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner F", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner G", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner H", + "type": "DVB-S2X NIM(45308X FBC) (DVB-S2X)", + "rec": "", + "live": "", + "stream": "" + }, + { + "name": "Tuner I", + "type": "GIGA DVB-T2/C NIM (TT2L10) (DVB-T2)", + "rec": "", + "live": "", + "stream": "" + } + ], + "ifaces": [ + { + "name": "eth0", + "friendlynic": "Broadcom Gigabit Ethernet", + "linkspeed": "1 GBit/s", + "mac": null, + "dhcp": true, + "ipv4method": "DHCP", + "ip": "192.168.1.100", + "mask": "255.255.255.0", + "v4prefix": 23, + "gw": "192.168.1.1", + "ipv6": "2003::2/64", + "ipmethod": "SL-AAC", + "firstpublic": "2003::2" + } + ], + "hdd": [ + { + "model": "ATA(ST2000LM015-2E81)", + "capacity": "1.8 TB", + "labelled_capacity": "2.0 TB", + "free": "22.5 GB", + "mount": "/media/hdd", + "friendlycapacity": "22.5 GB frei / 1.8 TB (2.0 TB) insgesamt" + } + ], + "shares": [ + { + "name": "NAS", + "method": "autofs", + "type": "SMBv2.0", + "mode": "r/w", + "path": "//192.168.1.2/NAS", + "host": "192.168.1.2", + "ipaddress": null, + "friendlyaddress": "192.168.1.2" + } + ], + "transcoding": true, + "EX": "", + "streams": [], + "timerpipzap": false, + "allow_duplicate": false, + "timermargins": true, + "textinputsupport": true + }, + "service": { + "result": false, + "name": "", + "namespace": "", + "aspect": 0, + "provider": "", + "width": 0, + "height": 0, + "apid": 0, + "vpid": 0, + "pcrpid": 0, + "pmtpid": 0, + "txtpid": "N/A", + "tsid": 0, + "onid": 0, + "sid": 0, + "ref": "", + "iswidescreen": false, + "bqref": "", + "bqname": "" + } +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_on.json b/tests/components/enigma2/fixtures/device_statusinfo_on.json new file mode 100644 index 00000000000..0c8701c7b74 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_on.json @@ -0,0 +1,20 @@ +{ + "volume": 100, + "muted": false, + "transcoding": true, + "currservice_filename": "", + "currservice_id": 38835, + "currservice_name": "Flucht aus Saudi-Arabien", + "currservice_serviceref": "1:0:19:2BA2:3F2:1:C00000:0:0:0:", + "currservice_begin": "16:30", + "currservice_begin_timestamp": 1734622200, + "currservice_end": "17:15", + "currservice_end_timestamp": 1734624900, + "currservice_description": "Ein M\u00e4dchen k\u00e4mpft um die Freiheit", + "currservice_station": "ZDFinfo HD", + "currservice_fulldescription": "Flucht aus Saudi-Arabien\n16:30 - 17:15\n\nSaudi-Arabien / Australien 2019\nIm streng islamischen K\u00f6nigreich Saudi-Arabien haben Frauen immer noch wenig Rechte. Wenn sie ein selbstbestimmtes Leben f\u00fchren wollen, bleibt ihnen h\u00e4ufig nur die Flucht.\n\nDie 18-j\u00e4hrige Rahaf Mohammed al-Qunun ist eine solche junge Frau, die riskiert hat, dem m\u00e4nnlich gepr\u00e4gten Vormundschaftssystem zu entfliehen. Doch der saudische Staat und Rahafs Familie verfolgen die Abtr\u00fcnnige sogar bis ins Ausland.\nHD-Produktion", + "inStandby": "false", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/fixtures/device_statusinfo_standby.json b/tests/components/enigma2/fixtures/device_statusinfo_standby.json new file mode 100644 index 00000000000..18cc19f5901 --- /dev/null +++ b/tests/components/enigma2/fixtures/device_statusinfo_standby.json @@ -0,0 +1,16 @@ +{ + "volume": 100, + "muted": true, + "transcoding": true, + "currservice_filename": "", + "currservice_id": -1, + "currservice_name": "N/A", + "currservice_begin": "", + "currservice_end": "", + "currservice_description": "", + "currservice_fulldescription": "N/A", + "inStandby": "true", + "isRecording": "false", + "Streaming_list": "", + "isStreaming": "false" +} diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index 8d32da42baf..1445048f0c1 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -1,19 +1,19 @@ """Test the Enigma2 config flow.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientError from openwebif.error import InvalidAuthError import pytest -from homeassistant import config_entries from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_FULL, TEST_REQUIRED, MockDevice +from .conftest import TEST_FULL, TEST_REQUIRED from tests.common import MockConfigEntry @@ -22,42 +22,35 @@ from tests.common import MockConfigEntry async def user_flow(hass: HomeAssistant) -> str: """Return a user-initiated flow after filling in host info.""" 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["errors"] is None return result["flow_id"] +@pytest.mark.usefixtures("openwebif_device_mock") @pytest.mark.parametrize( ("test_config"), [(TEST_FULL), (TEST_REQUIRED)], ) -async def test_form_user( - hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] -) -> None: +async def test_form_user(hass: HomeAssistant, test_config: dict[str, Any]) -> None: """Test a successful user initiated flow.""" - with ( - patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ), - patch( - "homeassistant.components.enigma2.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure(user_flow, test_config) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], test_config + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == test_config[CONF_HOST] assert result["data"] == test_config - assert len(mock_setup_entry.mock_calls) == 1 - @pytest.mark.parametrize( - ("exception", "error_type"), + ("side_effect", "error_value"), [ (InvalidAuthError, "invalid_auth"), (ClientError, "cannot_connect"), @@ -65,46 +58,87 @@ async def test_form_user( ], ) async def test_form_user_errors( - hass: HomeAssistant, user_flow, exception: Exception, error_type: str + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + side_effect: Exception, + error_value: str, ) -> None: """Test we handle errors.""" - with patch( - "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure(user_flow, TEST_FULL) + + openwebif_device_mock.get_about.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_FULL + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"] == {"base": error_type} + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {"base": error_value} + + openwebif_device_mock.get_about.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_FULL, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_FULL[CONF_HOST] + assert result["data"] == TEST_FULL + assert result["result"].unique_id == openwebif_device_mock.mac_address -async def test_options_flow(hass: HomeAssistant, user_flow: str) -> None: +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_duplicate_host( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that a duplicate host aborts the config flow.""" + mock_config_entry.add_to_hass(hass) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + result2 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_FULL + ) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.usefixtures("openwebif_device_mock") +async def test_options_flow(hass: HomeAssistant) -> None: """Test the form options.""" - with patch( - "openwebif.api.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert entry.options == {"source_bouquet": "Favourites (TV)"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == {"source_bouquet": "Favourites (TV)"} - await hass.async_block_till_done() + await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py index d12f96d4b0f..a3f68cd0902 100644 --- a/tests/components/enigma2/test_init.py +++ b/tests/components/enigma2/test_init.py @@ -1,46 +1,45 @@ """Test the Enigma2 integration init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock + +import pytest from homeassistant.components.enigma2.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import TEST_REQUIRED, MockDevice +from .conftest import TEST_REQUIRED -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_device_without_mac_address( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + openwebif_device_mock: AsyncMock, + device_registry: dr.DeviceRegistry, ) -> None: """Test that a device gets successfully registered when the device doesn't report a MAC address.""" - mock_device = MockDevice() - mock_device.mac_address = None - with patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=mock_device, - ): - entry = MockConfigEntry( - domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None + openwebif_device_mock.get_about.return_value = load_json_object_fixture( + "device_about_without_mac.json", DOMAIN + ) + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_REQUIRED, title="name", unique_id="123456" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.unique_id == "123456" + assert device_registry.async_get_device({(DOMAIN, entry.unique_id)}) is not None +@pytest.mark.usefixtures("openwebif_device_mock") async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" - with patch( - "homeassistant.components.enigma2.coordinator.OpenWebIfDevice.__new__", - return_value=MockDevice(), - ): - entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/enigma2/test_media_player.py b/tests/components/enigma2/test_media_player.py new file mode 100644 index 00000000000..dd1dcb66cb6 --- /dev/null +++ b/tests/components/enigma2/test_media_player.py @@ -0,0 +1,305 @@ +"""Tests for the media player module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from openwebif.api import OpenWebIfServiceEvent, OpenWebIfStatus +from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption +import pytest + +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.components.enigma2.media_player import ATTR_MEDIA_CURRENTLY_RECORDING +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, +) +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) + + +@pytest.mark.parametrize( + ("deep_standby", "powerstate"), + [(False, PowerState.STANDBY), (True, PowerState.DEEP_STANDBY)], +) +async def test_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + deep_standby: bool, + powerstate: PowerState, +) -> None: + """Test turning off the media player.""" + openwebif_device_mock.turn_off_to_deep = deep_standby + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_powerstate.assert_awaited_once_with(powerstate) + + +async def test_turn_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test turning on the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.turn_on.assert_awaited_once() + + +async def test_set_volume_level( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test setting the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_LEVEL: 0.2}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(20) + + +async def test_volume_up( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test increasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.1_1_1_1"} + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.UP) + + +async def test_volume_down( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test decreasing the volume of the media player.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.set_volume.assert_awaited_once_with(SetVolumeOption.DOWN) + + +@pytest.mark.parametrize( + ("service", "remote_code"), + [ + (SERVICE_MEDIA_STOP, RemoteControlCodes.STOP), + (SERVICE_MEDIA_PLAY, RemoteControlCodes.PLAY), + (SERVICE_MEDIA_PAUSE, RemoteControlCodes.PAUSE), + (SERVICE_MEDIA_NEXT_TRACK, RemoteControlCodes.CHANNEL_UP), + (SERVICE_MEDIA_PREVIOUS_TRACK, RemoteControlCodes.CHANNEL_DOWN), + ], +) +async def test_remote_control_actions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + service: str, + remote_code: RemoteControlCodes, +) -> None: + """Test media stop.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.1_1_1_1"}, + ) + + openwebif_device_mock.send_remote_control_action.assert_awaited_once_with( + remote_code + ) + + +@pytest.mark.parametrize("mute", [False, True]) +async def test_volume_mute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + mute: bool, +) -> None: + """Test mute.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_MEDIA_VOLUME_MUTED: mute}, + ) + + openwebif_device_mock.toggle_mute.assert_awaited_once() + + +async def test_select_source( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, +) -> None: + """Test media previous track.""" + openwebif_device_mock.return_value.sources = {"Test": "1"} + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.1_1_1_1", ATTR_INPUT_SOURCE: "Test"}, + ) + + openwebif_device_mock.zap.assert_awaited_once_with("1") + + +async def test_update_data_standby( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test data handling.""" + + openwebif_device_mock.get_status_info.return_value = load_json_object_fixture( + "device_statusinfo_standby.json", DOMAIN + ) + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=True + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_CURRENTLY_RECORDING + not in hass.states.get("media_player.1_1_1_1").attributes + ) + assert hass.states.get("media_player.1_1_1_1").state == MediaPlayerState.OFF + + +async def test_update_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=100 + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.1_1_1_1").attributes[ATTR_MEDIA_VOLUME_LEVEL] + > 0.99 + ) + + +async def test_update_volume_none( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + openwebif_device_mock: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test volume data handling.""" + + openwebif_device_mock.status = OpenWebIfStatus( + currservice=OpenWebIfServiceEvent(), in_standby=False, volume=None + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + ATTR_MEDIA_VOLUME_LEVEL + not in hass.states.get("media_player.1_1_1_1").attributes + ) diff --git a/tests/components/enocean/test_config_flow.py b/tests/components/enocean/test_config_flow.py index 96c0843906f..fb5b1de19d8 100644 --- a/tests/components/enocean/test_config_flow.py +++ b/tests/components/enocean/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_flow_cannot_create_multiple_instances(hass: HomeAssistant) - async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" FAKE_DONGLE_PATH = "/fake/dongle" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): @@ -42,13 +42,13 @@ async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "detect" - devices = result["data_schema"].schema.get("device").container + devices = result["data_schema"].schema.get(CONF_DEVICE).config.get("options") assert FAKE_DONGLE_PATH in devices assert EnOceanFlowHandler.MANUAL_PATH_VALUE in devices async def test_user_flow_with_no_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[])): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/enphase_envoy/__init__.py b/tests/components/enphase_envoy/__init__.py index f69ab8e44f2..f5381eda2a7 100644 --- a/tests/components/enphase_envoy/__init__.py +++ b/tests/components/enphase_envoy/__init__.py @@ -1,13 +1,19 @@ """Tests for the Enphase Envoy integration.""" +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Fixture for setting up the component.""" +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + expected_state: ConfigEntryState = ConfigEntryState.LOADED, +) -> None: + """Fixture for setting up the component and testing expected state.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is expected_state diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 05a6f265dfb..22aeca50ca0 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -93,7 +93,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 618b40027b8..52e812f979e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -235,7 +235,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 8118630200f..30fbc8d0f4f 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -223,7 +223,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 7affc1bea0d..6cfbfed1e8e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -427,7 +427,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index ff975b690ed..8c2767e33e5 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -242,7 +242,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 62df69c6d88..15cf2c173cb 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -88,7 +88,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index f936a9db76e..e4810c21226 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -255,7 +261,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower', + 'original_icon': None, 'original_name': 'Grid status', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -269,7 +275,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Enpower 654321 Grid status', - 'icon': 'mdi:transmission-tower', }), 'context': , 'entity_id': 'binary_sensor.enpower_654321_grid_status', diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 76835098f27..152cf803258 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -31,6 +33,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -68,6 +75,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -88,7 +96,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -101,7 +109,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -120,6 +127,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -140,7 +148,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -153,7 +161,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -170,6 +177,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -190,7 +198,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -203,7 +211,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -221,6 +228,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -241,7 +249,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -254,7 +262,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -270,6 +277,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -307,6 +319,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -321,7 +334,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -334,7 +347,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -351,6 +363,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': 'integration', 'domain': 'sensor', @@ -365,7 +378,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -454,6 +467,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -465,6 +480,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -502,6 +522,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -522,7 +543,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -535,7 +556,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -554,6 +574,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -574,7 +595,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -587,7 +608,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -604,6 +624,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -624,7 +645,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -637,7 +658,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -655,6 +675,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -675,7 +696,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -688,7 +709,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -704,6 +724,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -741,6 +766,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -755,7 +781,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -768,7 +794,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -785,6 +810,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': 'integration', 'domain': 'sensor', @@ -799,7 +825,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -928,6 +954,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -939,6 +967,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -976,6 +1009,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -996,7 +1030,7 @@ }), }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1009,7 +1043,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Envoy <> Current power production', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'kW', }), @@ -1028,6 +1061,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1048,7 +1082,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1061,7 +1095,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production today', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'kWh', }), @@ -1078,6 +1111,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1098,7 +1132,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1111,7 +1145,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': 'kWh', }), 'entity_id': 'sensor.envoy_<>_energy_production_last_seven_days', @@ -1129,6 +1162,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1149,7 +1183,7 @@ }), }), 'original_device_class': 'energy', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1162,7 +1196,6 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'Envoy <> Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': 'total_increasing', 'unit_of_measurement': 'MWh', }), @@ -1178,6 +1211,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1215,6 +1253,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1229,7 +1268,7 @@ 'options': dict({ }), 'original_device_class': 'power', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1242,7 +1281,6 @@ 'attributes': dict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': 'measurement', 'unit_of_measurement': 'W', }), @@ -1259,6 +1297,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': 'integration', 'domain': 'sensor', @@ -1273,7 +1312,7 @@ 'options': dict({ }), 'original_device_class': 'timestamp', - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, diff --git a/tests/components/enphase_envoy/snapshots/test_number.ambr b/tests/components/enphase_envoy/snapshots/test_number.ambr index b7e799c9ac8..eb8f5266f32 100644 --- a/tests/components/enphase_envoy/snapshots/test_number.ambr +++ b/tests/components/enphase_envoy/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -181,6 +184,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -237,6 +241,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +355,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +412,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_select.ambr b/tests/components/enphase_envoy/snapshots/test_select.ambr index f091879d9fc..d8238926dfd 100644 --- a/tests/components/enphase_envoy/snapshots/test_select.ambr +++ b/tests/components/enphase_envoy/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,6 +307,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -359,6 +365,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -418,6 +425,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +485,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +543,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -591,6 +601,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -650,6 +661,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -709,6 +721,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +779,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index d6a523a3e15..c1e2c9270e2 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -30,7 +31,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -45,7 +46,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -64,6 +64,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -86,7 +87,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -101,7 +102,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -121,6 +121,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -143,7 +144,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -158,7 +159,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -179,6 +179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -201,7 +202,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -216,7 +217,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -237,6 +237,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -253,7 +254,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -268,7 +269,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -287,6 +287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -303,7 +304,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -318,7 +319,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -337,6 +337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -359,7 +360,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -374,7 +375,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -395,6 +395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -417,7 +418,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -432,7 +433,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -453,6 +453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,7 +476,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -490,7 +491,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -511,6 +511,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,7 +534,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -548,7 +549,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -567,6 +567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -589,7 +590,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -604,7 +605,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -624,6 +624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -646,7 +647,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -661,7 +662,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -680,6 +680,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -702,7 +703,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -717,7 +718,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -737,6 +737,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -759,7 +760,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -774,7 +775,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -795,6 +795,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -814,7 +815,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -829,7 +830,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -850,6 +850,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -869,7 +870,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -884,7 +885,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -905,6 +905,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -927,7 +928,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -942,7 +943,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -963,6 +963,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -985,7 +986,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1000,7 +1001,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1021,6 +1021,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1043,7 +1044,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1058,7 +1059,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1079,6 +1079,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1101,7 +1102,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1116,7 +1117,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1137,6 +1137,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1159,7 +1160,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1174,7 +1175,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1193,11 +1193,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1209,7 +1210,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1223,7 +1224,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -1240,11 +1240,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1256,7 +1257,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1270,7 +1271,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -1293,11 +1293,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1309,7 +1310,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1324,7 +1325,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -1352,11 +1352,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -1368,7 +1369,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1383,7 +1384,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -1407,6 +1407,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1429,7 +1430,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1444,7 +1445,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1465,6 +1465,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1484,7 +1485,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1499,7 +1500,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -1519,6 +1519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1538,7 +1539,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1553,7 +1554,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -1573,6 +1573,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1595,7 +1596,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1610,7 +1611,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1631,6 +1631,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1653,7 +1654,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1668,7 +1669,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1689,6 +1689,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1711,7 +1712,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1726,7 +1727,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1747,6 +1747,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1763,7 +1764,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1778,7 +1779,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -1797,6 +1797,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1813,7 +1814,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -1828,7 +1829,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -1845,6 +1845,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1900,6 +1901,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1953,6 +1955,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2001,6 +2004,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2049,6 +2053,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2097,6 +2102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2144,6 +2150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2192,6 +2199,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2240,6 +2248,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2256,7 +2265,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Aggregated available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2271,7 +2280,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Aggregated available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2289,6 +2297,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2305,7 +2314,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Aggregated Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2320,7 +2329,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Aggregated Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2338,6 +2346,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2354,7 +2363,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Aggregated battery soc', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2369,7 +2378,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Aggregated battery soc', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -2387,6 +2395,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2403,7 +2412,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available ACB battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2418,7 +2427,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy_storage', 'friendly_name': 'Envoy 1234 Available ACB battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2436,6 +2444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2452,7 +2461,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2467,7 +2476,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2487,6 +2495,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2509,7 +2518,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2524,7 +2533,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2543,6 +2551,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2559,7 +2568,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2574,7 +2583,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -2592,6 +2600,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2608,7 +2617,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2623,7 +2632,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -2643,6 +2651,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2665,7 +2674,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2680,7 +2689,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2701,6 +2709,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2723,7 +2732,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2738,7 +2747,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2759,6 +2767,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2781,7 +2790,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2796,7 +2805,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2817,6 +2825,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2839,7 +2848,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2854,7 +2863,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2875,6 +2883,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2897,7 +2906,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2912,7 +2921,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2933,6 +2941,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2955,7 +2964,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -2970,7 +2979,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -2989,6 +2997,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3011,7 +3020,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3026,7 +3035,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -3046,6 +3054,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3068,7 +3077,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3083,7 +3092,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3102,6 +3110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3124,7 +3133,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3139,7 +3148,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -3159,6 +3167,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3181,7 +3190,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3196,7 +3205,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3217,6 +3225,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3236,7 +3245,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3251,7 +3260,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3272,6 +3280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3291,7 +3300,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3306,7 +3315,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3327,6 +3335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3346,7 +3355,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3361,7 +3370,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3382,6 +3390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3401,7 +3410,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3416,7 +3425,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3437,6 +3445,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3456,7 +3465,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3471,7 +3480,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3492,6 +3500,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3511,7 +3520,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3526,7 +3535,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3547,6 +3555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3566,7 +3575,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3581,7 +3590,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3602,6 +3610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3621,7 +3630,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3636,7 +3645,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3657,6 +3665,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3679,7 +3688,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3694,7 +3703,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3715,6 +3723,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3737,7 +3746,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3752,7 +3761,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3773,6 +3781,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3795,7 +3804,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3810,7 +3819,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3831,6 +3839,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3853,7 +3862,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3868,7 +3877,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3889,6 +3897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3911,7 +3920,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3926,7 +3935,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -3947,6 +3955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3969,7 +3978,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -3984,7 +3993,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4005,6 +4013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4027,7 +4036,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4042,7 +4051,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4063,6 +4071,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4085,7 +4094,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4100,7 +4109,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4121,6 +4129,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4143,7 +4152,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4158,7 +4167,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4179,6 +4187,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4201,7 +4210,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4216,7 +4225,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4237,6 +4245,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4259,7 +4268,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4274,7 +4283,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -4293,11 +4301,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4309,7 +4318,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4323,7 +4332,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -4340,11 +4348,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4356,7 +4365,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4370,7 +4379,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -4387,11 +4395,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4403,7 +4412,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4417,7 +4426,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -4434,11 +4442,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4450,7 +4459,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4464,7 +4473,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -4481,11 +4489,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4497,7 +4506,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4511,7 +4520,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -4528,11 +4536,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4544,7 +4553,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4558,7 +4567,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -4575,11 +4583,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4591,7 +4600,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4605,7 +4614,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -4622,11 +4630,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4638,7 +4647,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4652,7 +4661,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -4675,11 +4683,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4691,7 +4700,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4706,7 +4715,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4734,11 +4742,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4750,7 +4759,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4765,7 +4774,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4793,11 +4801,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -4809,7 +4818,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4824,7 +4833,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4852,11 +4860,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -4868,7 +4877,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4883,7 +4892,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4911,11 +4919,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -4927,7 +4936,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -4942,7 +4951,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -4970,11 +4978,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -4986,7 +4995,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5001,7 +5010,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -5029,11 +5037,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -5045,7 +5054,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5060,7 +5069,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -5088,11 +5096,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -5104,7 +5113,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5119,7 +5128,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -5143,6 +5151,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5165,7 +5174,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5180,7 +5189,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5201,6 +5209,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5223,7 +5232,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5238,7 +5247,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5259,6 +5267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5281,7 +5290,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5296,7 +5305,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5317,6 +5325,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5339,7 +5348,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5354,7 +5363,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5375,6 +5383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5394,7 +5403,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5409,7 +5418,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5429,6 +5437,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5448,7 +5457,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5463,7 +5472,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5483,6 +5491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5502,7 +5511,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5517,7 +5526,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5537,6 +5545,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5556,7 +5565,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5571,7 +5580,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5591,6 +5599,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5610,7 +5619,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5625,7 +5634,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5645,6 +5653,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5664,7 +5673,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5679,7 +5688,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5699,6 +5707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5718,7 +5727,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5733,7 +5742,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5753,6 +5761,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5772,7 +5781,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5787,7 +5796,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -5807,6 +5815,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5829,7 +5838,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5844,7 +5853,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5865,6 +5873,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5887,7 +5896,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5902,7 +5911,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5923,6 +5931,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5945,7 +5954,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -5960,7 +5969,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -5981,6 +5989,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6003,7 +6012,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6018,7 +6027,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6037,6 +6045,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6053,7 +6062,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6068,7 +6077,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -6086,6 +6094,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6102,7 +6111,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6117,7 +6126,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -6137,6 +6145,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6159,7 +6168,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6174,7 +6183,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6195,6 +6203,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6217,7 +6226,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6232,7 +6241,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6253,6 +6261,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6275,7 +6284,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6290,7 +6299,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6311,6 +6319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6333,7 +6342,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6348,7 +6357,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6369,6 +6377,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6391,7 +6400,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6406,7 +6415,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6427,6 +6435,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6449,7 +6458,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6464,7 +6473,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6485,6 +6493,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6507,7 +6516,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6522,7 +6531,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6543,6 +6551,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6565,7 +6574,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6580,7 +6589,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6601,6 +6609,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6617,7 +6626,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6632,7 +6641,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -6651,6 +6659,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6667,7 +6676,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6682,7 +6691,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -6699,6 +6707,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6747,6 +6756,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6795,6 +6805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6842,6 +6853,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6890,6 +6902,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6938,6 +6951,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6954,7 +6968,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -6969,7 +6983,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -6989,6 +7002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7011,7 +7025,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7026,7 +7040,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7045,6 +7058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7061,7 +7075,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7076,7 +7090,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -7094,6 +7107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7110,7 +7124,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7125,7 +7139,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7145,6 +7158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7167,7 +7181,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7182,7 +7196,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7203,6 +7216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7225,7 +7239,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7240,7 +7254,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7261,6 +7274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7283,7 +7297,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7298,7 +7312,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7319,6 +7332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7341,7 +7355,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7356,7 +7370,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7377,6 +7390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7399,7 +7413,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7414,7 +7428,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7435,6 +7448,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7457,7 +7471,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7472,7 +7486,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7491,6 +7504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7513,7 +7527,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7528,7 +7542,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7548,6 +7561,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7570,7 +7584,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7585,7 +7599,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7604,6 +7617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7626,7 +7640,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7641,7 +7655,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -7661,6 +7674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7683,7 +7697,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7698,7 +7712,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7719,6 +7732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7738,7 +7752,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7753,7 +7767,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7774,6 +7787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7793,7 +7807,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7808,7 +7822,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7829,6 +7842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7848,7 +7862,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7863,7 +7877,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7884,6 +7897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7903,7 +7917,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7918,7 +7932,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7939,6 +7952,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7958,7 +7972,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -7973,7 +7987,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -7994,6 +8007,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8013,7 +8027,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8028,7 +8042,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8049,6 +8062,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8068,7 +8082,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8083,7 +8097,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8104,6 +8117,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8123,7 +8137,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8138,7 +8152,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8159,6 +8172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8181,7 +8195,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8196,7 +8210,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8217,6 +8230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8239,7 +8253,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8254,7 +8268,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8275,6 +8288,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8297,7 +8311,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8312,7 +8326,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8333,6 +8346,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8355,7 +8369,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8370,7 +8384,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8391,6 +8404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8413,7 +8427,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8428,7 +8442,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8449,6 +8462,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8471,7 +8485,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8486,7 +8500,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8507,6 +8520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8529,7 +8543,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8544,7 +8558,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8565,6 +8578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8587,7 +8601,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8602,7 +8616,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8623,6 +8636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8645,7 +8659,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8660,7 +8674,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8681,6 +8694,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8703,7 +8717,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8718,7 +8732,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8739,6 +8752,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8761,7 +8775,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8776,7 +8790,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -8795,11 +8808,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -8811,7 +8825,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8825,7 +8839,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -8842,11 +8855,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -8858,7 +8872,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8872,7 +8886,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -8889,11 +8902,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -8905,7 +8919,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8919,7 +8933,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -8936,11 +8949,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -8952,7 +8966,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -8966,7 +8980,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -8983,11 +8996,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -8999,7 +9013,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9013,7 +9027,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -9030,11 +9043,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -9046,7 +9060,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9060,7 +9074,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -9077,11 +9090,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -9093,7 +9107,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9107,7 +9121,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -9124,11 +9137,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -9140,7 +9154,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9154,7 +9168,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -9177,11 +9190,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -9193,7 +9207,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9208,7 +9222,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9236,11 +9249,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -9252,7 +9266,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9267,7 +9281,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9295,11 +9308,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -9311,7 +9325,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9326,7 +9340,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9354,11 +9367,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -9370,7 +9384,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9385,7 +9399,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9413,11 +9426,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -9429,7 +9443,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9444,7 +9458,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9472,11 +9485,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -9488,7 +9502,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9503,7 +9517,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9531,11 +9544,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -9547,7 +9561,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9562,7 +9576,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9590,11 +9603,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -9606,7 +9620,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9621,7 +9635,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -9645,6 +9658,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9667,7 +9681,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9682,7 +9696,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9703,6 +9716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9725,7 +9739,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9740,7 +9754,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9761,6 +9774,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9783,7 +9797,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9798,7 +9812,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9819,6 +9832,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9841,7 +9855,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9856,7 +9870,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -9877,6 +9890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9896,7 +9910,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9911,7 +9925,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -9931,6 +9944,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9950,7 +9964,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -9965,7 +9979,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -9985,6 +9998,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10004,7 +10018,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10019,7 +10033,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10039,6 +10052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10058,7 +10072,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10073,7 +10087,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10093,6 +10106,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10112,7 +10126,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10127,7 +10141,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10147,6 +10160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10166,7 +10180,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10181,7 +10195,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10201,6 +10214,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10220,7 +10234,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10235,7 +10249,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10255,6 +10268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10274,7 +10288,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10289,7 +10303,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -10309,6 +10322,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10331,7 +10345,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10346,7 +10360,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10367,6 +10380,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10389,7 +10403,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10404,7 +10418,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10425,6 +10438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10447,7 +10461,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10462,7 +10476,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10483,6 +10496,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10505,7 +10519,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10520,7 +10534,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10539,6 +10552,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10555,7 +10569,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10570,7 +10584,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -10588,6 +10601,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10604,7 +10618,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10619,7 +10633,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -10639,6 +10652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10661,7 +10675,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10676,7 +10690,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10697,6 +10710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10719,7 +10733,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10734,7 +10748,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10755,6 +10768,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10777,7 +10791,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10792,7 +10806,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10813,6 +10826,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10835,7 +10849,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10850,7 +10864,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10871,6 +10884,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10893,7 +10907,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10908,7 +10922,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10929,6 +10942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10951,7 +10965,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -10966,7 +10980,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -10987,6 +11000,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11009,7 +11023,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11024,7 +11038,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11045,6 +11058,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11067,7 +11081,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11082,7 +11096,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11103,6 +11116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11119,7 +11133,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11134,7 +11148,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11153,6 +11166,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11169,7 +11183,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11184,7 +11198,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -11201,6 +11214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11249,6 +11263,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11297,6 +11312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11344,6 +11360,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11392,6 +11409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11440,6 +11458,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11487,6 +11506,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11535,6 +11555,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11551,7 +11572,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Available battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11566,7 +11587,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Available battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -11586,6 +11606,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11608,7 +11629,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11623,7 +11644,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11644,6 +11664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11666,7 +11687,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11681,7 +11702,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11702,6 +11722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11724,7 +11745,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11739,7 +11760,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11760,6 +11780,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11782,7 +11803,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11797,7 +11818,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11816,6 +11836,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11832,7 +11853,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11847,7 +11868,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Battery', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -11865,6 +11885,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11881,7 +11902,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Battery capacity', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11896,7 +11917,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Battery capacity', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -11916,6 +11936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11938,7 +11959,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -11953,7 +11974,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -11974,6 +11994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11996,7 +12017,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12011,7 +12032,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12032,6 +12052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12054,7 +12075,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12069,7 +12090,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12090,6 +12110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12112,7 +12133,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current battery discharge l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12127,7 +12148,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current battery discharge l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12148,6 +12168,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12170,7 +12191,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12185,7 +12206,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12206,6 +12226,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12228,7 +12249,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12243,7 +12264,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12264,6 +12284,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12286,7 +12307,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12301,7 +12322,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12322,6 +12342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12344,7 +12365,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12359,7 +12380,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12380,6 +12400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12402,7 +12423,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12417,7 +12438,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12438,6 +12458,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12460,7 +12481,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12475,7 +12496,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12496,6 +12516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12518,7 +12539,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12533,7 +12554,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12554,6 +12574,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12576,7 +12597,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12591,7 +12612,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12612,6 +12632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12634,7 +12655,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12649,7 +12670,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12670,6 +12690,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12692,7 +12713,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12707,7 +12728,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12728,6 +12748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12750,7 +12771,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12765,7 +12786,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12786,6 +12806,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12808,7 +12829,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12823,7 +12844,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -12842,6 +12862,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12864,7 +12885,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12879,7 +12900,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -12897,6 +12917,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12919,7 +12940,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12934,7 +12955,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -12952,6 +12972,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12974,7 +12995,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -12989,7 +13010,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13007,6 +13027,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13029,7 +13050,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13044,7 +13065,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13064,6 +13084,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13086,7 +13107,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13101,7 +13122,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13122,6 +13142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13144,7 +13165,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13159,7 +13180,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13180,6 +13200,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13202,7 +13223,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13217,7 +13238,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13238,6 +13258,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13260,7 +13281,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13275,7 +13296,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13294,6 +13314,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13316,7 +13337,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13331,7 +13352,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13349,6 +13369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13371,7 +13392,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13386,7 +13407,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13404,6 +13424,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13426,7 +13447,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13441,7 +13462,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13459,6 +13479,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13481,7 +13502,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13496,7 +13517,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -13516,6 +13536,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13538,7 +13559,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13553,7 +13574,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13574,6 +13594,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13596,7 +13617,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13611,7 +13632,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13632,6 +13652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13654,7 +13675,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13669,7 +13690,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13690,6 +13710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13712,7 +13733,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13727,7 +13748,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13748,6 +13768,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13767,7 +13788,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13782,7 +13803,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13803,6 +13823,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13822,7 +13843,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13837,7 +13858,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13858,6 +13878,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13877,7 +13898,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13892,7 +13913,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13913,6 +13933,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13932,7 +13953,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -13947,7 +13968,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -13968,6 +13988,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13987,7 +14008,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14002,7 +14023,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14023,6 +14043,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14042,7 +14063,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14057,7 +14078,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14078,6 +14098,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14097,7 +14118,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14112,7 +14133,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14133,6 +14153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14152,7 +14173,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14167,7 +14188,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14188,6 +14208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14207,7 +14228,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14222,7 +14243,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14243,6 +14263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14262,7 +14283,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14277,7 +14298,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14298,6 +14318,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14317,7 +14338,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14332,7 +14353,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14353,6 +14373,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14372,7 +14393,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14387,7 +14408,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency storage CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14408,6 +14428,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14430,7 +14451,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14445,7 +14466,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14466,6 +14486,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14488,7 +14509,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14503,7 +14524,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14524,6 +14544,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14546,7 +14567,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14561,7 +14582,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14582,6 +14602,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14604,7 +14625,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14619,7 +14640,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14640,6 +14660,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14662,7 +14683,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14677,7 +14698,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14698,6 +14718,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14720,7 +14741,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14735,7 +14756,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14756,6 +14776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14778,7 +14799,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14793,7 +14814,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14814,6 +14834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14836,7 +14857,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy charged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14851,7 +14872,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy charged l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14872,6 +14892,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14894,7 +14915,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14909,7 +14930,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14930,6 +14950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14952,7 +14973,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -14967,7 +14988,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -14988,6 +15008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15010,7 +15031,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15025,7 +15046,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15046,6 +15066,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15068,7 +15089,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime battery energy discharged l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15083,7 +15104,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime battery energy discharged l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15104,6 +15124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15126,7 +15147,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15141,7 +15162,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15162,6 +15182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15184,7 +15205,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15199,7 +15220,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15220,6 +15240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15242,7 +15263,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15257,7 +15278,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15278,6 +15298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15300,7 +15321,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15315,7 +15336,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15336,6 +15356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15358,7 +15379,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15373,7 +15394,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15394,6 +15414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15416,7 +15437,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15431,7 +15452,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15452,6 +15472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15474,7 +15495,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15489,7 +15510,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15510,6 +15530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15532,7 +15553,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15547,7 +15568,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15568,6 +15588,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15590,7 +15611,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15605,7 +15626,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15626,6 +15646,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15648,7 +15669,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15663,7 +15684,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15684,6 +15704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15706,7 +15727,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15721,7 +15742,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15742,6 +15762,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15764,7 +15785,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15779,7 +15800,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15800,6 +15820,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15822,7 +15843,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15837,7 +15858,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15858,6 +15878,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15880,7 +15901,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15895,7 +15916,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15916,6 +15936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15938,7 +15959,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -15953,7 +15974,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -15974,6 +15994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15996,7 +16017,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16011,7 +16032,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -16030,11 +16050,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16046,7 +16067,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16060,7 +16081,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -16077,11 +16097,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16093,7 +16114,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16107,7 +16128,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -16124,11 +16144,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16140,7 +16161,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16154,7 +16175,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -16171,11 +16191,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16187,7 +16208,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16201,7 +16222,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -16218,11 +16238,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16234,7 +16255,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16248,7 +16269,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -16265,11 +16285,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16281,7 +16302,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16295,7 +16316,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -16312,11 +16332,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16328,7 +16349,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16342,7 +16363,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -16359,11 +16379,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16375,7 +16396,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16389,7 +16410,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -16406,11 +16426,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16422,7 +16443,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16436,7 +16457,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct', @@ -16453,11 +16473,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16469,7 +16490,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16483,7 +16504,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l1', @@ -16500,11 +16520,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16516,7 +16537,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16530,7 +16551,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l2', @@ -16547,11 +16567,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16563,7 +16584,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16577,7 +16598,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active storage CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_storage_ct_l3', @@ -16600,11 +16620,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16616,7 +16637,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16631,7 +16652,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16659,11 +16679,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16675,7 +16696,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16690,7 +16711,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16718,11 +16738,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16734,7 +16755,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16749,7 +16770,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16777,11 +16797,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -16793,7 +16814,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16808,7 +16829,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16836,11 +16856,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -16852,7 +16873,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16867,7 +16888,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16895,11 +16915,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -16911,7 +16932,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16926,7 +16947,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -16954,11 +16974,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -16970,7 +16991,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -16985,7 +17006,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17013,11 +17033,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -17029,7 +17050,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17044,7 +17065,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17072,11 +17092,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct', 'has_entity_name': True, 'hidden_by': None, @@ -17088,7 +17109,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17103,7 +17124,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17131,11 +17151,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -17147,7 +17168,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17162,7 +17183,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17190,11 +17210,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -17206,7 +17227,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17221,7 +17242,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17249,11 +17269,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -17265,7 +17286,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17280,7 +17301,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status storage CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -17304,6 +17324,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17326,7 +17347,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17341,7 +17362,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17362,6 +17382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17384,7 +17405,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17399,7 +17420,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17420,6 +17440,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17442,7 +17463,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17457,7 +17478,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17478,6 +17498,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17500,7 +17521,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17515,7 +17536,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -17536,6 +17556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17555,7 +17576,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17570,7 +17591,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17590,6 +17610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17609,7 +17630,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17624,7 +17645,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17644,6 +17664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17663,7 +17684,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17678,7 +17699,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17698,6 +17718,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17717,7 +17738,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17732,7 +17753,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17752,6 +17772,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17771,7 +17792,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17786,7 +17807,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17806,6 +17826,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17825,7 +17846,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17840,7 +17861,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17860,6 +17880,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17879,7 +17900,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17894,7 +17915,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17914,6 +17934,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17933,7 +17954,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -17948,7 +17969,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -17968,6 +17988,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17987,7 +18008,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18002,7 +18023,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18022,6 +18042,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18041,7 +18062,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18056,7 +18077,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18076,6 +18096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18095,7 +18116,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18110,7 +18131,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18130,6 +18150,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18149,7 +18170,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18164,7 +18185,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -18184,6 +18204,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18206,7 +18227,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18221,7 +18242,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18242,6 +18262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18264,7 +18285,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18279,7 +18300,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18300,6 +18320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18322,7 +18343,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18337,7 +18358,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18358,6 +18378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18380,7 +18401,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18395,7 +18416,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18414,6 +18434,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18430,7 +18451,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery energy', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18445,7 +18466,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Reserve battery energy', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -18463,6 +18483,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18479,7 +18500,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Reserve battery level', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18494,7 +18515,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Envoy 1234 Reserve battery level', - 'icon': 'mdi:flash', 'unit_of_measurement': '%', }), 'context': , @@ -18514,6 +18534,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18536,7 +18557,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18551,7 +18572,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18572,6 +18592,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18594,7 +18615,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18609,7 +18630,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18630,6 +18650,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18652,7 +18673,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18667,7 +18688,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18688,6 +18708,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18710,7 +18731,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Storage CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18725,7 +18746,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Storage CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18746,6 +18766,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18768,7 +18789,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18783,7 +18804,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18804,6 +18824,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18826,7 +18847,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18841,7 +18862,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18862,6 +18882,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18884,7 +18905,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18899,7 +18920,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18920,6 +18940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18942,7 +18963,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -18957,7 +18978,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -18978,6 +18998,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19000,7 +19021,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19015,7 +19036,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19036,6 +19056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19058,7 +19079,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19073,7 +19094,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19094,6 +19114,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19116,7 +19137,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19131,7 +19152,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19152,6 +19172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19174,7 +19195,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19189,7 +19210,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19210,6 +19230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19232,7 +19253,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19247,7 +19268,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19268,6 +19288,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19290,7 +19311,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19305,7 +19326,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19326,6 +19346,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19348,7 +19369,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19363,7 +19384,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19384,6 +19404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19406,7 +19427,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19421,7 +19442,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage storage CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19442,6 +19462,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19458,7 +19479,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19473,7 +19494,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19492,6 +19512,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19508,7 +19529,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19523,7 +19544,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -19542,6 +19562,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19564,7 +19585,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19579,7 +19600,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19600,6 +19620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19622,7 +19643,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19637,7 +19658,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19658,6 +19678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19680,7 +19701,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19695,7 +19716,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19716,6 +19736,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19738,7 +19759,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19753,7 +19774,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19774,6 +19794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19796,7 +19817,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19811,7 +19832,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19832,6 +19852,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19854,7 +19875,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19869,7 +19890,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19890,6 +19910,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19912,7 +19933,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19927,7 +19948,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -19948,6 +19968,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19970,7 +19991,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -19985,7 +20006,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current net power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20006,6 +20026,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20028,7 +20049,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20043,7 +20064,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20064,6 +20084,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20086,7 +20107,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20101,7 +20122,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20122,6 +20142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20144,7 +20165,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20159,7 +20180,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20180,6 +20200,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20202,7 +20223,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20217,7 +20238,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20238,6 +20258,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20260,7 +20281,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20275,7 +20296,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20296,6 +20316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20318,7 +20339,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20333,7 +20354,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20354,6 +20374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20376,7 +20397,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20391,7 +20412,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20412,6 +20432,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20434,7 +20455,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20449,7 +20470,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20468,6 +20488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20490,7 +20511,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20505,7 +20526,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20523,6 +20543,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20545,7 +20566,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20560,7 +20581,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20578,6 +20598,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20600,7 +20621,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20615,7 +20636,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20633,6 +20653,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20655,7 +20676,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20670,7 +20691,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20690,6 +20710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20712,7 +20733,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20727,7 +20748,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20748,6 +20768,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20770,7 +20791,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20785,7 +20806,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20806,6 +20826,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20828,7 +20849,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20843,7 +20864,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20864,6 +20884,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20886,7 +20907,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy consumption today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20901,7 +20922,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy consumption today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -20920,6 +20940,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20942,7 +20963,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -20957,7 +20978,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -20975,6 +20995,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20997,7 +21018,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21012,7 +21033,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l1', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -21030,6 +21050,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21052,7 +21073,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21067,7 +21088,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l2', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -21085,6 +21105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21107,7 +21128,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21122,7 +21143,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days l3', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -21142,6 +21162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21164,7 +21185,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21179,7 +21200,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21200,6 +21220,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21222,7 +21243,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21237,7 +21258,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21258,6 +21278,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21280,7 +21301,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21295,7 +21316,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21316,6 +21336,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21338,7 +21359,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21353,7 +21374,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21374,6 +21394,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21393,7 +21414,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21408,7 +21429,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21429,6 +21449,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21448,7 +21469,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21463,7 +21484,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21484,6 +21504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21503,7 +21524,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21518,7 +21539,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21539,6 +21559,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21558,7 +21579,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21573,7 +21594,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21594,6 +21614,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21613,7 +21634,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21628,7 +21649,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21649,6 +21669,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21668,7 +21689,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21683,7 +21704,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21704,6 +21724,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21723,7 +21744,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21738,7 +21759,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21759,6 +21779,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21778,7 +21799,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21793,7 +21814,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21814,6 +21834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21836,7 +21857,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21851,7 +21872,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21872,6 +21892,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21894,7 +21915,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21909,7 +21930,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21930,6 +21950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21952,7 +21973,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -21967,7 +21988,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -21988,6 +22008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22010,7 +22031,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22025,7 +22046,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22046,6 +22066,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22068,7 +22089,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22083,7 +22104,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22104,6 +22124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22126,7 +22147,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22141,7 +22162,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22162,6 +22182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22184,7 +22205,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22199,7 +22220,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22220,6 +22240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22242,7 +22263,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22257,7 +22278,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22278,6 +22298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22300,7 +22321,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22315,7 +22336,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22336,6 +22356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22358,7 +22379,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22373,7 +22394,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22394,6 +22414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22416,7 +22437,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22431,7 +22452,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22452,6 +22472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22474,7 +22495,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22489,7 +22510,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22510,6 +22530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22532,7 +22553,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22547,7 +22568,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22568,6 +22588,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22590,7 +22611,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22605,7 +22626,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22626,6 +22646,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22648,7 +22669,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22663,7 +22684,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22684,6 +22704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22706,7 +22727,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22721,7 +22742,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22742,6 +22762,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22764,7 +22785,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22779,7 +22800,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22800,6 +22820,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22822,7 +22843,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22837,7 +22858,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22858,6 +22878,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22880,7 +22901,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22895,7 +22916,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22916,6 +22936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22938,7 +22959,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime net energy production l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -22953,7 +22974,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -22972,11 +22992,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -22988,7 +23009,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23002,7 +23023,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', @@ -23019,11 +23039,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -23035,7 +23056,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23049,7 +23070,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', @@ -23066,11 +23086,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -23082,7 +23103,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23096,7 +23117,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', @@ -23113,11 +23133,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -23129,7 +23150,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23143,7 +23164,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', @@ -23160,11 +23180,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -23176,7 +23197,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23190,7 +23211,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -23207,11 +23227,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -23223,7 +23244,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23237,7 +23258,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', @@ -23254,11 +23274,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -23270,7 +23291,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23284,7 +23305,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', @@ -23301,11 +23321,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -23317,7 +23338,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23331,7 +23352,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', @@ -23354,11 +23374,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, @@ -23370,7 +23391,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23385,7 +23406,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23413,11 +23433,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -23429,7 +23450,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23444,7 +23465,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23472,11 +23492,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -23488,7 +23509,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23503,7 +23524,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23531,11 +23551,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -23547,7 +23568,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23562,7 +23583,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23590,11 +23610,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -23606,7 +23627,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23621,7 +23642,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23649,11 +23669,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, @@ -23665,7 +23686,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23680,7 +23701,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l1', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23708,11 +23728,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, @@ -23724,7 +23745,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23739,7 +23760,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l2', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23767,11 +23787,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, @@ -23783,7 +23804,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23798,7 +23819,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT l3', - 'icon': 'mdi:flash', 'options': list([ , , @@ -23822,6 +23842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23844,7 +23865,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23859,7 +23880,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -23880,6 +23900,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23902,7 +23923,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23917,7 +23938,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -23938,6 +23958,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23960,7 +23981,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -23975,7 +23996,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -23996,6 +24016,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24018,7 +24039,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Net consumption CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24033,7 +24054,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Net consumption CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24054,6 +24074,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24073,7 +24094,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24088,7 +24109,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24108,6 +24128,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24127,7 +24148,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24142,7 +24163,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24162,6 +24182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24181,7 +24202,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24196,7 +24217,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24216,6 +24236,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24235,7 +24256,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24250,7 +24271,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24270,6 +24290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24289,7 +24310,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24304,7 +24325,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24324,6 +24344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24343,7 +24364,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24358,7 +24379,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24378,6 +24398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24397,7 +24418,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24412,7 +24433,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24432,6 +24452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24451,7 +24472,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Powerfactor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24466,7 +24487,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -24486,6 +24506,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24508,7 +24529,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24523,7 +24544,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24544,6 +24564,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24566,7 +24587,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24581,7 +24602,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24602,6 +24622,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24624,7 +24645,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24639,7 +24660,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24660,6 +24680,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24682,7 +24703,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24697,7 +24718,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24718,6 +24738,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24740,7 +24761,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24755,7 +24776,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24776,6 +24796,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24798,7 +24819,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24813,7 +24834,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24834,6 +24854,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24856,7 +24877,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24871,7 +24892,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24892,6 +24912,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24914,7 +24935,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24929,7 +24950,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -24950,6 +24970,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24972,7 +24993,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -24987,7 +25008,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25008,6 +25028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25030,7 +25051,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25045,7 +25066,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25066,6 +25086,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25088,7 +25109,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25103,7 +25124,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l2', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25124,6 +25144,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25146,7 +25167,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25161,7 +25182,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT l3', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25182,6 +25202,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25198,7 +25219,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25213,7 +25234,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25232,6 +25252,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25248,7 +25269,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25263,7 +25284,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', @@ -25282,6 +25302,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25304,7 +25325,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25319,7 +25340,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 balanced net power consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25340,6 +25360,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25362,7 +25383,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Current power production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25377,7 +25398,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Envoy 1234 Current power production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25396,6 +25416,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25418,7 +25439,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production last seven days', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25433,7 +25454,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production last seven days', - 'icon': 'mdi:flash', 'unit_of_measurement': , }), 'context': , @@ -25453,6 +25473,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25475,7 +25496,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25490,7 +25511,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Energy production today', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25511,6 +25531,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25530,7 +25551,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Frequency production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25545,7 +25566,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'frequency', 'friendly_name': 'Envoy 1234 Frequency production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25566,6 +25586,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25588,7 +25609,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime balanced net energy consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25603,7 +25624,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25624,6 +25644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25646,7 +25667,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25661,7 +25682,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Envoy 1234 Lifetime energy production', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25680,11 +25700,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -25696,7 +25717,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Meter status flags active production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25710,7 +25731,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Envoy 1234 Meter status flags active production CT', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', @@ -25733,11 +25753,12 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', 'has_entity_name': True, 'hidden_by': None, @@ -25749,7 +25770,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Metering status production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25764,7 +25785,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Envoy 1234 Metering status production CT', - 'icon': 'mdi:flash', 'options': list([ , , @@ -25788,6 +25808,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25807,7 +25828,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'powerfactor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25822,7 +25843,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', 'friendly_name': 'Envoy 1234 powerfactor production CT', - 'icon': 'mdi:flash', 'state_class': , }), 'context': , @@ -25842,6 +25862,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25864,7 +25885,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Production CT current', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25879,7 +25900,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'current', 'friendly_name': 'Envoy 1234 Production CT current', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25900,6 +25920,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25922,7 +25943,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Voltage production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25937,7 +25958,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', 'friendly_name': 'Envoy 1234 Voltage production CT', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -25958,6 +25978,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25974,7 +25995,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': None, 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -25989,7 +26010,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Inverter 1', - 'icon': 'mdi:flash', 'state_class': , 'unit_of_measurement': , }), @@ -26008,6 +26028,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -26024,7 +26045,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:flash', + 'original_icon': None, 'original_name': 'Last reported', 'platform': 'enphase_envoy', 'previous_unique_id': None, @@ -26039,7 +26060,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'Inverter 1 Last reported', - 'icon': 'mdi:flash', }), 'context': , 'entity_id': 'sensor.inverter_1_last_reported', diff --git a/tests/components/enphase_envoy/snapshots/test_switch.ambr b/tests/components/enphase_envoy/snapshots/test_switch.ambr index 46123c03cec..77b682cb948 100644 --- a/tests/components/enphase_envoy/snapshots/test_switch.ambr +++ b/tests/components/enphase_envoy/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,7 +169,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC1_relay_status', 'unit_of_measurement': None, }) @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,7 +216,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC2_relay_status', 'unit_of_measurement': None, }) @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,7 +263,7 @@ 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'relay_status', 'unique_id': '654321_relay_NC3_relay_status', 'unit_of_measurement': None, }) diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 121c2583050..efbe6da9b13 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock from pyenphase import EnvoyAuthenticationError, EnvoyError import pytest -from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import ( DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, @@ -19,6 +18,7 @@ from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_integration @@ -163,7 +163,7 @@ async def test_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -273,7 +273,7 @@ async def test_zeroconf_serial_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -301,7 +301,7 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", @@ -330,7 +330,7 @@ async def test_zeroconf_host_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -363,7 +363,7 @@ async def test_zero_conf_while_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", @@ -396,7 +396,7 @@ async def test_zero_conf_second_envoy_while_form( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("4.4.4.4"), ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", @@ -434,125 +434,12 @@ async def test_zero_conf_second_envoy_while_form( assert result4["type"] is FlowResultType.ABORT -async def test_zero_conf_malformed_serial_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - with pytest.raises(KeyError) as ex: - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serilnum": "1234", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert "serialnum" in str(ex.value) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.ABORT - - -async def test_zero_conf_malformed_serial( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf properties.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "12%4", "protovers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Envoy 12%4" - - -async def test_zero_conf_malformed_fw_property( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_setup_entry: AsyncMock, - mock_envoy: AsyncMock, -) -> None: - """Test malformed zeroconf property.""" - await setup_integration(hass, config_entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("1.1.1.1"), - ip_addresses=[ip_address("1.1.1.1")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={"serialnum": "1234", "protvers": "7.1.2"}, - type="mock_type", - ), - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert config_entry.data[CONF_HOST] == "1.1.1.1" - assert config_entry.unique_id == "1234" - assert config_entry.title == "Envoy 1234" - - async def test_zero_conf_old_blank_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, ) -> None: - """Test re-using old blank entry.""" + """Test reusing old blank entry.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -568,7 +455,7 @@ async def test_zero_conf_old_blank_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -591,7 +478,7 @@ async def test_zero_conf_old_blank_entry_standard_title( mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, ) -> None: - """Test re-using old blank entry was Envoy as title.""" + """Test reusing old blank entry was Envoy as title.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -609,7 +496,7 @@ async def test_zero_conf_old_blank_entry_standard_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", @@ -632,7 +519,7 @@ async def test_zero_conf_old_blank_entry_user_title( mock_setup_entry: AsyncMock, mock_envoy: AsyncMock, ) -> None: - """Test re-using old blank entry with user title.""" + """Test reusing old blank entry with user title.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -650,7 +537,7 @@ async def test_zero_conf_old_blank_entry_user_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1"), ip_address("1.1.1.2")], hostname="mock_hostname", diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 2b35aaff5e9..93a150cfc5c 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -1,6 +1,8 @@ """Test Enphase Envoy runtime.""" -from unittest.mock import AsyncMock, patch +from datetime import timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory from jwt import encode @@ -15,7 +17,10 @@ from homeassistant.components.enphase_envoy.const import ( OPTION_DISABLE_KEEP_ALIVE, Platform, ) -from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL +from homeassistant.components.enphase_envoy.coordinator import ( + FIRMWARE_REFRESH_INTERVAL, + SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -47,8 +52,6 @@ async def test_with_pre_v7_firmware( ) await setup_integration(hass, config_entry) - assert config_entry.state is ConfigEntryState.LOADED - assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -79,8 +82,6 @@ async def test_token_in_config_file( ) mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -124,8 +125,6 @@ async def test_expired_token_in_config( cloud_password="test_password", ) await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -225,9 +224,6 @@ async def test_coordinator_token_refresh_error( ): await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED - assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -250,7 +246,6 @@ async def test_config_no_unique_id( }, ) await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED assert entry.unique_id == mock_envoy.serial_number @@ -263,7 +258,7 @@ async def test_config_different_unique_id( domain=DOMAIN, entry_id="45a36e55aaddb2007c5f6602e0c38e72", title="Envoy 1234", - unique_id=4321, + unique_id="4321", data={ CONF_HOST: "1.1.1.1", CONF_NAME: "Envoy 1234", @@ -271,8 +266,7 @@ async def test_config_different_unique_id( CONF_PASSWORD: "test-password", }, ) - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.SETUP_RETRY + await setup_integration(hass, entry, expected_state=ConfigEntryState.SETUP_RETRY) @pytest.mark.parametrize( @@ -293,7 +287,6 @@ async def test_remove_config_entry_device( """Test removing enphase_envoy config entry device.""" assert await async_setup_component(hass, "config", {}) await setup_integration(hass, config_entry) - assert config_entry.state is ConfigEntryState.LOADED # use client to send remove_device command hass_client = await hass_ws_client(hass) @@ -344,10 +337,10 @@ async def test_option_change_reload( ) -> None: """Test options change will reload entity.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED + # By default neither option is available + assert config_entry.options == {} - # option change will take care of COV of init::async_reload_entry + # option change will also take care of COV of init::async_reload_entry hass.config_entries.async_update_entry( config_entry, options={ @@ -355,8 +348,98 @@ async def test_option_change_reload( OPTION_DISABLE_KEEP_ALIVE: True, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == { OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: False, OPTION_DISABLE_KEEP_ALIVE: True, } + # flip em + hass.config_entries.async_update_entry( + config_entry, + options={ + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == { + OPTION_DIAGNOSTICS_INCLUDE_FIXTURES: True, + OPTION_DISABLE_KEEP_ALIVE: False, + } + + +def mock_envoy_setup(mock_envoy: AsyncMock): + """Mock envoy.setup.""" + mock_envoy.firmware = "9.9.9999" + + +@patch( + "homeassistant.components.enphase_envoy.coordinator.SCAN_INTERVAL", + timedelta(days=1), +) +@respx.mock +async def test_coordinator_firmware_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + + # Move time to next firmware check moment + # SCAN_INTERVAL is patched to 1 day to disable it's firmware detection + mock_envoy.setup.reset_mock() + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_envoy.setup.assert_called_once_with() + mock_envoy.setup.reset_mock() + + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "7.6.175" + + caplog.set_level(logging.WARNING) + + with patch( + "homeassistant.components.enphase_envoy.Envoy.setup", + MagicMock(return_value=mock_envoy_setup(mock_envoy)), + ): + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "Envoy firmware changed from: 7.6.175 to: 9.9.9999, reloading config entry Envoy 1234" + in caplog.text + ) + envoy = config_entry.runtime_data.envoy + assert envoy.firmware == "9.9.9999" + + +@respx.mock +async def test_coordinator_firmware_refresh_with_envoy_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator scheduled firmware check.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + mock_envoy.setup.side_effect = EnvoyError + freezer.tick(FIRMWARE_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error reading firmware:" in caplog.text diff --git a/tests/components/enphase_envoy/test_number.py b/tests/components/enphase_envoy/test_number.py index dbf711cacaa..07826174c7d 100644 --- a/tests/components/enphase_envoy/test_number.py +++ b/tests/components/enphase_envoy/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -13,6 +14,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -21,9 +23,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( @@ -40,14 +42,14 @@ async def test_number( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_number( hass: HomeAssistant, @@ -62,10 +64,10 @@ async def test_no_number( @pytest.mark.parametrize( - ("mock_envoy", "use_serial"), + ("mock_envoy", "use_serial", "expected_value", "test_value"), [ - ("envoy_metered_batt_relay", "enpower_654321"), - ("envoy_eu_batt", "envoy_1234"), + ("envoy_metered_batt_relay", "enpower_654321", 15.0, 30.0), + ("envoy_eu_batt", "envoy_1234", 0.0, 80.0), ], indirect=["mock_envoy"], ) @@ -74,6 +76,8 @@ async def test_number_operation_storage( mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: bool, + expected_value: float, + test_value: float, ) -> None: """Test enphase_envoy number storage entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -82,10 +86,8 @@ async def test_number_operation_storage( test_entity = f"{Platform.NUMBER}.{use_serial}_reserve_battery_level" assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.tariff.storage_settings.reserved_soc == float( - entity_state.state - ) - test_value = 30.0 + assert float(entity_state.state) == expected_value + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -100,29 +102,120 @@ async def test_number_operation_storage( @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy", "use_serial", "target", "test_value"), + [ + ("envoy_metered_batt_relay", "enpower_654321", "reserve_battery_level", 30.0), + ], + indirect=["mock_envoy"], +) +async def test_number_operation_storage_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: bool, + target: str, + test_value: float, +) -> None: + """Test enphase_envoy number storage entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry) + + test_entity = f"number.{use_serial}_{target}" + + mock_envoy.set_reserve_soc.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_set_native_value for {test_entity}, host", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +@pytest.mark.parametrize( + ("relay", "target", "expected_value", "test_value", "test_field"), + [ + ("NC1", "cutoff_battery_level", 25.0, 15.0, "soc_low"), + ("NC1", "restore_battery_level", 70.0, 75.0, "soc_high"), + ("NC2", "cutoff_battery_level", 30.0, 25.0, "soc_low"), + ("NC2", "restore_battery_level", 70.0, 80.0, "soc_high"), + ("NC3", "cutoff_battery_level", 30.0, 45.0, "soc_low"), + ("NC3", "restore_battery_level", 70.0, 90.0, "soc_high"), + ], ) async def test_number_operation_relays( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay: str, + target: str, + expected_value: float, + test_value: float, + test_field: str, ) -> None: """Test enphase_envoy number relay entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): await setup_integration(hass, config_entry) - entity_base = f"{Platform.NUMBER}." + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - for counter, (contact_id, dry_contact) in enumerate( - mock_envoy.data.dry_contact_settings.items() + test_entity = f"number.{name}_{target}" + + assert (entity_state := hass.states.get(test_entity)) + assert float(entity_state.state) == expected_value + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) + + mock_envoy.update_dry_contact.assert_awaited_once_with( + {"id": relay, test_field: int(test_value)} + ) + + +@pytest.mark.parametrize( + ("mock_envoy", "relay", "target", "test_value"), + [ + ("envoy_metered_batt_relay", "NC1", "cutoff_battery_level", 15.0), + ], + indirect=["mock_envoy"], +) +async def test_number_operation_relays_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, + target: str, + test_value: float, +) -> None: + """Test enphase_envoy number relay entities operation with error returned.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry) + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"number.{name}_{target}" + + mock_envoy.update_dry_contact.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_set_native_value for {test_entity}, host", ): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_cutoff_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_low == float( - entity_state.state - ) - test_value = 10.0 + counter await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -132,29 +225,3 @@ async def test_number_operation_relays( }, blocking=True, ) - - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_low": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() - - test_entity = f"{entity_base}{name}_restore_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_high == float( - entity_state.state - ) - test_value = 80.0 - counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) - - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_high": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index 071dbcb2fe2..a81a06a3441 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -2,24 +2,23 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.enphase_envoy.const import Platform from homeassistant.components.enphase_envoy.select import ( - ACTION_OPTIONS, - MODE_OPTIONS, RELAY_ACTION_MAP, RELAY_MODE_MAP, REVERSE_RELAY_ACTION_MAP, REVERSE_RELAY_MODE_MAP, REVERSE_STORAGE_MODE_MAP, STORAGE_MODE_MAP, - STORAGE_MODE_OPTIONS, ) from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -28,9 +27,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_select( @@ -47,14 +46,14 @@ async def test_select( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_select( hass: HomeAssistant, @@ -68,13 +67,31 @@ async def test_no_select( assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("relay", "target", "expected_state", "call_parameter"), + [ + ("NC1", "generator_action", "shed", "generator_action"), + ("NC1", "microgrid_action", "shed", "micro_grid_action"), + ("NC1", "grid_action", "shed", "grid_action"), + ("NC2", "generator_action", "shed", "generator_action"), + ("NC2", "microgrid_action", "shed", "micro_grid_action"), + ("NC2", "grid_action", "apply", "grid_action"), + ("NC3", "generator_action", "apply", "generator_action"), + ("NC3", "microgrid_action", "apply", "micro_grid_action"), + ("NC3", "grid_action", "shed", "grid_action"), + ], ) +@pytest.mark.parametrize("action", ["powered", "not_powered", "schedule", "none"]) async def test_select_relay_actions( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + target: str, + expected_state: str, + call_parameter: str, + relay: str, + action: str, ) -> None: """Test select platform entities dry contact relay actions.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -82,54 +99,37 @@ async def test_select_relay_actions( entity_base = f"{Platform.SELECT}." - for contact_id, dry_contact in mock_envoy.data.dry_contact_settings.items(): - name = dry_contact.load_name.lower().replace(" ", "_") - for target in ( - ("generator_action", dry_contact.generator_action, "generator_action"), - ("microgrid_action", dry_contact.micro_grid_action, "micro_grid_action"), - ("grid_action", dry_contact.grid_action, "grid_action"), - ): - test_entity = f"{entity_base}{name}_{target[0]}" - assert (entity_state := hass.states.get(test_entity)) - assert RELAY_ACTION_MAP[target[1]] == (current_state := entity_state.state) - # set all relay modes except current mode - for action in [action for action in ACTION_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: action, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, target[2]: REVERSE_RELAY_ACTION_MAP[action]} - ) - mock_envoy.update_dry_contact.reset_mock() - # and finally back to original - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, target[2]: REVERSE_RELAY_ACTION_MAP[current_state]} - ) - mock_envoy.update_dry_contact.reset_mock() + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}_{target}" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == RELAY_ACTION_MAP[expected_state] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: action, + }, + blocking=True, + ) + mock_envoy.update_dry_contact.assert_called_once_with( + {"id": relay, call_parameter: REVERSE_RELAY_ACTION_MAP[action]} + ) -@pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] -) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +@pytest.mark.parametrize("relay_mode", ["battery", "standard"]) +@pytest.mark.parametrize("relay", ["NC1", "NC2", "NC3"]) async def test_select_relay_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay_mode: str, + relay: str, ) -> None: """Test select platform dry contact relay mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -137,40 +137,66 @@ async def test_select_relay_modes( entity_base = f"{Platform.SELECT}." - for contact_id, dry_contact in mock_envoy.data.dry_contact_settings.items(): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_mode" - assert (entity_state := hass.states.get(test_entity)) - assert RELAY_MODE_MAP[dry_contact.mode] == (current_state := entity_state.state) - for mode in [mode for mode in MODE_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: mode, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, "mode": REVERSE_RELAY_MODE_MAP[mode]} - ) - mock_envoy.update_dry_contact.reset_mock() + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - # and finally current mode again + test_entity = f"{entity_base}{name}_mode" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == RELAY_MODE_MAP[dry_contact.mode] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: relay_mode, + }, + blocking=True, + ) + mock_envoy.update_dry_contact.assert_called_once_with( + {"id": relay, "mode": REVERSE_RELAY_MODE_MAP[relay_mode]} + ) + + +@pytest.mark.parametrize( + ("mock_envoy", "relay", "target", "action"), + [("envoy_metered_batt_relay", "NC1", "generator_action", "powered")], + indirect=["mock_envoy"], +) +async def test_update_dry_contact_actions_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + target: str, + relay: str, + action: str, +) -> None: + """Test select platform update dry contact action with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SELECT}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}_{target}" + + mock_envoy.update_dry_contact.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, + ATTR_OPTION: action, }, blocking=True, ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, "mode": REVERSE_RELAY_MODE_MAP[current_state]} - ) - mock_envoy.update_dry_contact.reset_mock() @pytest.mark.parametrize( @@ -181,11 +207,13 @@ async def test_select_relay_modes( ], indirect=["mock_envoy"], ) +@pytest.mark.parametrize(("mode"), ["backup", "self_consumption", "savings"]) async def test_select_storage_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: str, + mode: str, ) -> None: """Test select platform entities storage mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -194,11 +222,50 @@ async def test_select_storage_modes( test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" assert (entity_state := hass.states.get(test_entity)) - assert STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] == ( - current_state := entity_state.state + assert ( + entity_state.state + == STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] ) - for mode in [mode for mode in STORAGE_MODE_OPTIONS if not current_state]: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: mode, + }, + blocking=True, + ) + mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode]) + + +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +@pytest.mark.parametrize(("mode"), ["backup"]) +async def test_set_storage_modes_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, + mode: str, +) -> None: + """Test select platform set storage mode with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + mock_envoy.set_storage_mode.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -208,21 +275,28 @@ async def test_select_storage_modes( }, blocking=True, ) - mock_envoy.set_storage_mode.assert_called_once_with( - REVERSE_STORAGE_MODE_MAP[mode] - ) - mock_envoy.set_storage_mode.reset_mock() - # and finally with original mode - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, - }, - blocking=True, - ) - mock_envoy.set_storage_mode.assert_called_once_with( - REVERSE_STORAGE_MODE_MAP[current_state] - ) + +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_select_storage_modes_if_none( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test select platform entity storage mode when tariff storage_mode is none.""" + mock_envoy.data.tariff.storage_settings.mode = None + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == "unknown" diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index f30cba4d201..d15c0ad740f 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -112,6 +114,46 @@ async def test_switch_grid_operation( mock_envoy.go_off_grid.reset_mock() +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +async def test_switch_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test switch platform operation for grid switches when error occurs.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.data.enpower.serial_number + test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled" + + mock_envoy.go_off_grid.side_effect = EnvoyError("Test") + mock_envoy.go_on_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation( mock_envoy.disable_charge_from_grid.reset_mock() +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_switch_charge_from_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test switch platform operation for charge from grid switches.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid" + + mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test") + mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "entity_states"), [ @@ -232,3 +321,51 @@ async def test_switch_relay_operation( assert mock_envoy.close_dry_contact.await_count == close_count mock_envoy.open_dry_contact.reset_mock() mock_envoy.close_dry_contact.reset_mock() + + +@pytest.mark.parametrize( + ("mock_envoy", "relay"), + [("envoy_metered_batt_relay", "NC1")], + indirect=["mock_envoy"], +) +async def test_switch_relay_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, +) -> None: + """Test enphase_envoy switch relay entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SWITCH}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}" + + mock_envoy.close_dry_contact.side_effect = EnvoyError("Test") + mock_envoy.open_dry_contact.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) diff --git a/tests/components/environment_canada/__init__.py b/tests/components/environment_canada/__init__.py index 92c28e09b74..edc7a92a12f 100644 --- a/tests/components/environment_canada/__init__.py +++ b/tests/components/environment_canada/__init__.py @@ -37,6 +37,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: weather_mock.conditions = ec_data["conditions"] weather_mock.alerts = ec_data["alerts"] weather_mock.daily_forecasts = ec_data["daily_forecasts"] + weather_mock.hourly_forecasts = ec_data["hourly_forecasts"] weather_mock.metadata = ec_data["metadata"] radar_mock = mock_ec() diff --git a/tests/components/environment_canada/conftest.py b/tests/components/environment_canada/conftest.py index 69cec187d11..19180052c93 100644 --- a/tests/components/environment_canada/conftest.py +++ b/tests/components/environment_canada/conftest.py @@ -19,6 +19,9 @@ def ec_data(): if t := weather.get("timestamp"): with contextlib.suppress(ValueError): weather["timestamp"] = datetime.fromisoformat(t) + elif t := weather.get("period"): + with contextlib.suppress(ValueError): + weather["period"] = datetime.fromisoformat(t) return weather return json.loads( diff --git a/tests/components/environment_canada/fixtures/current_conditions_data.json b/tests/components/environment_canada/fixtures/current_conditions_data.json index ceb00028f95..e3b9563ef0b 100644 --- a/tests/components/environment_canada/fixtures/current_conditions_data.json +++ b/tests/components/environment_canada/fixtures/current_conditions_data.json @@ -238,6 +238,224 @@ "timestamp": "2022-10-09 15:00:00+00:00" } ], + "hourly_forecasts": [ + { + "period": "2025-02-19T19:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -11, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T20:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -10, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T21:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -10, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T22:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -11, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T23:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -11, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T00:00:00+00:00", + "condition": "Cloudy", + "temperature": -12, + "icon_code": "10", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T01:00:00+00:00", + "condition": "Cloudy", + "temperature": -13, + "icon_code": "10", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T02:00:00+00:00", + "condition": "Cloudy", + "temperature": -13, + "icon_code": "10", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T03:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -14, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T04:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -14, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T05:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -15, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T06:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -15, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T07:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -15, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T08:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -16, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T09:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -16, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T10:00:00+00:00", + "condition": "Partly cloudy", + "temperature": -16, + "icon_code": "32", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T11:00:00+00:00", + "condition": "Partly cloudy", + "temperature": -16, + "icon_code": "32", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T12:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -16, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 5, + "wind_direction": "VR" + }, + { + "period": "2025-02-20T13:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -15, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 5, + "wind_direction": "VR" + }, + { + "period": "2025-02-20T14:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -14, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 5, + "wind_direction": "VR" + }, + { + "period": "2025-02-20T15:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -13, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "NW" + }, + { + "period": "2025-02-20T16:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -11, + "icon_code": "03", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "NW" + }, + { + "period": "2025-02-20T17:00:00+00:00", + "condition": "Periods of light snow", + "temperature": -10, + "icon_code": "16", + "precip_probability": 70, + "wind_speed": 10, + "wind_direction": "NW" + }, + { + "period": "2025-02-20T18:00:00+00:00", + "condition": "Periods of light snow", + "temperature": -8, + "icon_code": "16", + "precip_probability": 70, + "wind_speed": 20, + "wind_direction": "NW" + } + ], "metadata": { "attribution": "Data provided by Environment Canada", "timestamp": "2022/10/3", diff --git a/tests/components/environment_canada/snapshots/test_weather.ambr b/tests/components/environment_canada/snapshots/test_weather.ambr index cfa0ad912a4..46dcacce8a4 100644 --- a/tests/components/environment_canada/snapshots/test_weather.ambr +++ b/tests/components/environment_canada/snapshots/test_weather.ambr @@ -92,3 +92,337 @@ }), }) # --- +# name: test_get_environment_canada_raw_forecast_data + dict({ + 'weather.home_forecast': dict({ + 'daily_forecast': list([ + dict({ + 'icon_code': '30', + 'period': 'Monday night', + 'precip_probability': 0, + 'temperature': -1, + 'temperature_class': 'low', + 'text_summary': 'Clear. Fog patches developing after midnight. Low minus 1 with frost.', + 'timestamp': '2022-10-03T15:00:00+00:00', + }), + dict({ + 'icon_code': '00', + 'period': 'Tuesday', + 'precip_probability': 0, + 'temperature': 18, + 'temperature_class': 'high', + 'text_summary': 'Sunny. Fog patches dissipating in the morning. High 18. UV index 5 or moderate.', + 'timestamp': '2022-10-04T15:00:00+00:00', + }), + dict({ + 'icon_code': '30', + 'period': 'Tuesday night', + 'precip_probability': 0, + 'temperature': 3, + 'temperature_class': 'low', + 'text_summary': 'Clear. Fog patches developing overnight. Low plus 3.', + 'timestamp': '2022-10-04T15:00:00+00:00', + }), + dict({ + 'icon_code': '00', + 'period': 'Wednesday', + 'precip_probability': 0, + 'temperature': 20, + 'temperature_class': 'high', + 'text_summary': 'Sunny. High 20.', + 'timestamp': '2022-10-05T15:00:00+00:00', + }), + dict({ + 'icon_code': '30', + 'period': 'Wednesday night', + 'precip_probability': 0, + 'temperature': 9, + 'temperature_class': 'low', + 'text_summary': 'Clear. Low 9.', + 'timestamp': '2022-10-05T15:00:00+00:00', + }), + dict({ + 'icon_code': '02', + 'period': 'Thursday', + 'precip_probability': 0, + 'temperature': 20, + 'temperature_class': 'high', + 'text_summary': 'A mix of sun and cloud. High 20.', + 'timestamp': '2022-10-06T15:00:00+00:00', + }), + dict({ + 'icon_code': '12', + 'period': 'Thursday night', + 'precip_probability': 0, + 'temperature': 7, + 'temperature_class': 'low', + 'text_summary': 'Showers. Low 7.', + 'timestamp': '2022-10-06T15:00:00+00:00', + }), + dict({ + 'icon_code': '12', + 'period': 'Friday', + 'precip_probability': 40, + 'temperature': 13, + 'temperature_class': 'high', + 'text_summary': 'Cloudy with 40 percent chance of showers. High 13.', + 'timestamp': '2022-10-07T15:00:00+00:00', + }), + dict({ + 'icon_code': '32', + 'period': 'Friday night', + 'precip_probability': 0, + 'temperature': 1, + 'temperature_class': 'low', + 'text_summary': 'Cloudy periods. Low plus 1.', + 'timestamp': '2022-10-07T15:00:00+00:00', + }), + dict({ + 'icon_code': '02', + 'period': 'Saturday', + 'precip_probability': 0, + 'temperature': 10, + 'temperature_class': 'high', + 'text_summary': 'A mix of sun and cloud. High 10.', + 'timestamp': '2022-10-08T15:00:00+00:00', + }), + dict({ + 'icon_code': '32', + 'period': 'Saturday night', + 'precip_probability': 0, + 'temperature': 3, + 'temperature_class': 'low', + 'text_summary': 'Cloudy periods. Low plus 3.', + 'timestamp': '2022-10-08T15:00:00+00:00', + }), + dict({ + 'icon_code': '02', + 'period': 'Sunday', + 'precip_probability': 0, + 'temperature': 12, + 'temperature_class': 'high', + 'text_summary': 'A mix of sun and cloud. High 12.', + 'timestamp': '2022-10-09T15:00:00+00:00', + }), + ]), + 'hourly_forecast': list([ + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-19T19:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -10, + 'timestamp': '2025-02-19T20:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -10, + 'timestamp': '2025-02-19T21:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-19T22:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-19T23:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Cloudy', + 'icon_code': '10', + 'precip_probability': 20, + 'temperature': -12, + 'timestamp': '2025-02-20T00:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Cloudy', + 'icon_code': '10', + 'precip_probability': 20, + 'temperature': -13, + 'timestamp': '2025-02-20T01:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Cloudy', + 'icon_code': '10', + 'precip_probability': 20, + 'temperature': -13, + 'timestamp': '2025-02-20T02:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -14, + 'timestamp': '2025-02-20T03:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -14, + 'timestamp': '2025-02-20T04:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T05:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T06:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T07:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T08:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T09:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Partly cloudy', + 'icon_code': '32', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T10:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Partly cloudy', + 'icon_code': '32', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T11:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T12:00:00+00:00', + 'wind_direction': 'VR', + 'wind_speed': 5, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T13:00:00+00:00', + 'wind_direction': 'VR', + 'wind_speed': 5, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -14, + 'timestamp': '2025-02-20T14:00:00+00:00', + 'wind_direction': 'VR', + 'wind_speed': 5, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -13, + 'timestamp': '2025-02-20T15:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '03', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-20T16:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Periods of light snow', + 'icon_code': '16', + 'precip_probability': 70, + 'temperature': -10, + 'timestamp': '2025-02-20T17:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Periods of light snow', + 'icon_code': '16', + 'precip_probability': 70, + 'temperature': -8, + 'timestamp': '2025-02-20T18:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 20, + }), + ]), + }), + }) +# --- diff --git a/tests/components/environment_canada/test_weather.py b/tests/components/environment_canada/test_weather.py index 8e22f68462f..06166f41bca 100644 --- a/tests/components/environment_canada/test_weather.py +++ b/tests/components/environment_canada/test_weather.py @@ -5,6 +5,10 @@ from typing import Any from syrupy.assertion import SnapshotAssertion +from homeassistant.components.environment_canada.const import ( + DOMAIN, + SERVICE_ENVIRONMENT_CANADA_FORECASTS, +) from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, @@ -56,3 +60,22 @@ async def test_forecast_daily_with_some_previous_days_data( return_response=True, ) assert response == snapshot + + +async def test_get_environment_canada_raw_forecast_data( + hass: HomeAssistant, snapshot: SnapshotAssertion, ec_data: dict[str, Any] +) -> None: + """Test forecast with half day at start.""" + + await init_integration(hass, ec_data) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_ENVIRONMENT_CANADA_FORECASTS, + { + "entity_id": "weather.home_forecast", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 2b7c127efd3..2786ed8324c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -6,7 +6,7 @@ import asyncio from asyncio import Event from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( @@ -17,6 +17,7 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + LogLevel, ReconnectLogic, UserService, VoiceAssistantAnnounceFinished, @@ -29,6 +30,7 @@ from zeroconf import Zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, @@ -42,6 +44,10 @@ from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG from tests.common import MockConfigEntry +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import SubscribeLogsResponse + + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -154,6 +160,7 @@ def mock_client(mock_device_info) -> APIClient: mock_client.device_info = AsyncMock(return_value=mock_device_info) mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() + mock_client.subscribe_logs = Mock() mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) @@ -222,7 +229,9 @@ class MockESPHomeDevice: ] | None ) + self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info + self.current_log_level = LogLevel.LOG_LEVEL_NONE def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -250,6 +259,16 @@ class MockESPHomeDevice: """Mock disconnecting.""" await self.on_disconnect(expected_disconnect) + def set_on_log_message( + self, on_log_message: Callable[[SubscribeLogsResponse], None] + ) -> None: + """Set the log message callback.""" + self.on_log_message = on_log_message + + def mock_on_log_message(self, log_message: SubscribeLogsResponse) -> None: + """Mock on log message.""" + self.on_log_message(log_message) + def set_on_connect(self, on_connect: Callable[[], None]) -> None: """Set the connect callback.""" self.on_connect = on_connect @@ -413,6 +432,14 @@ async def _mock_generic_device_entry( on_state_sub, on_state_request ) + def _subscribe_logs( + on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel + ) -> Callable[[], None]: + """Subscribe to log messages.""" + mock_device.set_on_log_message(on_log_message) + mock_device.current_log_level = log_level + return lambda: None + def _subscribe_voice_assistant( *, handle_start: Callable[ @@ -453,6 +480,7 @@ async def _mock_generic_device_entry( mock_client.subscribe_states = _subscribe_states mock_client.subscribe_service_calls = _subscribe_service_calls mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states + mock_client.subscribe_logs = _subscribe_logs try_connect_done = Event() @@ -551,12 +579,29 @@ async def mock_bluetooth_entry( async def _mock_bluetooth_entry( bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) return await _mock_generic_device_entry( hass, mock_client, - {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + { + "bluetooth_mac_address": "AA:BB:CC:DD:EE:FC", + "bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags, + }, ([], []), [], + entry=entry, ) return _mock_bluetooth_entry diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 4f7ea679b20..8f1711e829e 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'ESPHome Device', 'unique_id': '11:22:33:44:55:aa', 'version': 1, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 5ca333df1e2..3281a760c39 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -25,7 +25,7 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, tts +from homeassistant.components import assist_satellite, conversation, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, @@ -48,8 +48,11 @@ from homeassistant.components.select import ( ) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, intent as intent_helper -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent as intent_helper, +) from homeassistant.helpers.entity_component import EntityComponent from .conftest import MockESPHomeDevice @@ -282,12 +285,21 @@ async def test_pipeline_api_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + continue_conversation=True, + ).as_dict() + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, - {"conversation_id": conversation_id}, + { + "conversation_id": conversation_id, + "continue_conversation": "1", + }, ) # TTS @@ -481,7 +493,12 @@ async def test_pipeline_udp_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) @@ -687,7 +704,12 @@ async def test_pipeline_media_player( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 46858c5826b..dd7a8f59fe5 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -1,7 +1,10 @@ """Test the ESPHome bluetooth integration.""" +from unittest.mock import patch + from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import MockESPHomeDevice @@ -10,7 +13,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -18,11 +21,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -30,7 +33,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -38,9 +41,56 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True + + +async def test_bluetooth_device_linked_via_device( + hass: HomeAssistant, + mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the Bluetooth device is linked to the ESPHome device.""" + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") + assert scanner.connectable is True + entry = hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", "AA:BB:CC:DD:EE:FC" + ) + assert entry is not None + esp_device = device_registry.async_get_device( + connections={ + ( + dr.CONNECTION_NETWORK_MAC, + mock_bluetooth_entry_with_raw_adv.device_info.mac_address, + ) + } + ) + assert esp_device is not None + device = device_registry.async_get_device( + connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")} + ) + assert device is not None + assert device.via_device_id == esp_device.id + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") + assert scanner.connectable is True + await hass.config_entries.async_unload( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + + with patch("homeassistant.components.esphome.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove( + mock_bluetooth_entry_with_raw_adv.entry.entry_id + ) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, scanner.source) diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 2a5013444dd..03d2f78a5d2 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -407,7 +407,7 @@ async def test_climate_entity_with_inf_value( target_temperature=math.inf, fan_mode=ClimateFanMode.AUTO, swing_mode=ClimateSwingMode.BOTH, - current_humidity=20.1, + current_humidity=math.inf, target_humidity=25.7, ) ] @@ -422,7 +422,7 @@ async def test_climate_entity_with_inf_value( assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes - assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert ATTR_CURRENT_HUMIDITY not in attributes assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 0a389969c78..afca6f76b43 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -18,20 +18,22 @@ import aiohttp import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import VALID_NOISE_PSK @@ -126,7 +128,7 @@ async def test_user_sets_unique_id( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -205,7 +207,7 @@ async def test_user_causes_zeroconf_to_abort( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -568,7 +570,7 @@ async def test_discovery_initiation( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery importing works.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -601,7 +603,7 @@ async def test_discovery_no_mac( hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -629,7 +631,7 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -650,7 +652,7 @@ async def test_discovery_duplicate_data( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: """Test discovery aborts if same mDNS packet arrives.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", @@ -685,7 +687,7 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1056,7 +1058,7 @@ async def test_discovery_dhcp_updates_host( ) entry.add_to_hass(hass) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test8266", macaddress="1122334455aa", @@ -1083,7 +1085,7 @@ async def test_discovery_dhcp_no_changes( mock_client.device_info = AsyncMock(return_value=DeviceInfo(name="test8266")) - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.183", hostname="test8266", macaddress="000000000000", @@ -1132,7 +1134,7 @@ async def test_zeroconf_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1198,7 +1200,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( mock_setup_entry: None, ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1264,7 +1266,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard( mock_setup_entry: None, ) -> None: """Test encryption key not retrieved from dashboard.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.43.183"), ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", @@ -1294,14 +1296,57 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["step_id"] == "encryption_key" -@pytest.mark.parametrize("option_value", [True, False]) -async def test_option_flow( +async def test_option_flow_allow_service_calls( hass: HomeAssistant, - option_value: bool, mock_client: APIClient, mock_generic_device_entry, ) -> None: - """Test config flow options.""" + """Test config flow options for allow service calls.""" + entry = await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + with patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_reload: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_ALLOW_SERVICE_CALLS: True, CONF_SUBSCRIBE_LOGS: False}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: True, + CONF_SUBSCRIBE_LOGS: False, + } + assert len(mock_reload.mock_calls) == 1 + + +async def test_option_flow_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( mock_client=mock_client, entity_info=[], @@ -1314,7 +1359,8 @@ async def test_option_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["data_schema"]({}) == { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, } with patch( @@ -1322,15 +1368,16 @@ async def test_option_flow( ) as mock_reload: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ - CONF_ALLOW_SERVICE_CALLS: option_value, - }, + user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: True}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value} - assert len(mock_reload.mock_calls) == int(option_value) + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: False, + CONF_SUBSCRIBE_LOGS: True, + } + assert len(mock_reload.mock_calls) == 1 @pytest.mark.usefixtures("mock_zeroconf") diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 832e7d6572f..2d64170bc97 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -37,7 +37,7 @@ async def test_diagnostics_with_bluetooth( mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, ) -> None: """Test diagnostics for config entry with Bluetooth.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True entry = mock_bluetooth_entry_with_raw_adv.entry @@ -49,13 +49,15 @@ async def test_diagnostics_with_bluetooth( "connections_limit": 0, "scanner": { "connectable": True, + "current_mode": None, + "requested_mode": None, "discovered_device_timestamps": {}, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, - "name": "test (11:22:33:44:55:AA)", + "name": "test (AA:BB:CC:DD:EE:FC)", "scanning": True, - "source": "11:22:33:44:55:AA", + "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", @@ -64,6 +66,7 @@ async def test_diagnostics_with_bluetooth( "config": { "created_at": ANY, "data": { + "bluetooth_mac_address": "**REDACTED**", "device_name": "test", "host": "test.local", "password": "", @@ -79,6 +82,7 @@ async def test_diagnostics_with_bluetooth( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "11:22:33:44:55:aa", "version": 1, @@ -86,6 +90,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", "esphome_version": "1.0.0", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 4b322c8744e..905a3f6bdc7 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,7 +2,8 @@ import asyncio from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock, call +import logging +from unittest.mock import AsyncMock, Mock, call from aioesphomeapi import ( APIClient, @@ -13,6 +14,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, RequiresEncryptionAPIError, UserService, UserServiceArg, @@ -21,11 +23,13 @@ from aioesphomeapi import ( import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DOMAIN, + STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) from homeassistant.const import ( @@ -37,6 +41,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -44,6 +49,95 @@ from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry, async_capture_events, async_mock_service +async def test_esphome_device_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test configuring a device to subscribe to logs.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_SUBSCRIBE_LOGS: True}, + ) + entry.add_to_hass(hass) + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={}, + states=[], + ) + await hass.async_block_till_done() + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text + + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text + + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text + + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "ERROR"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "INFO"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, @@ -273,7 +367,7 @@ async def test_esphome_device_with_old_bluetooth( ) assert ( issue.learn_more_url - == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) @@ -383,6 +477,39 @@ async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> Non assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") +async def test_add_missing_bluetooth_mac_address( + hass: HomeAssistant, mock_client +) -> None: + """Test bluetooth mac is added if its missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + subscribe_done = hass.loop.create_future() + + def async_subscribe_states(*args, **kwargs) -> None: + subscribe_done.set_result(None) + + mock_client.subscribe_states = async_subscribe_states + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await subscribe_done + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient @@ -598,7 +725,7 @@ async def test_connection_aborted_wrong_device( mock_client.disconnect = AsyncMock() caplog.clear() # Make sure discovery triggers a reconnect - service_info = dhcp.DhcpServiceInfo( + service_info = DhcpServiceInfo( ip="192.168.43.184", hostname="test", macaddress="1122334455aa", @@ -1100,6 +1227,42 @@ async def test_esphome_device_with_web_server( assert dev.configuration_url == "http://test.local:80" +async def test_esphome_device_with_ipv6_web_server( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a web server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={}, + ) + entry.add_to_hass(hass) + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={"webserver_port": 80}, + states=[], + ) + await hass.async_block_till_done() + entry = device.entry + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.configuration_url == "http://[fe80::1]:80" + + async def test_esphome_device_with_compilation_time( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1209,3 +1372,32 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_entry_missing_bluetooth_mac_address( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test the bluetooth_mac_address is added if available.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_ALLOW_SERVICE_CALLS: True}, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + mock_storage=True, + device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, + ) + await hass.async_block_till_done() + assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 42b7e72a06e..a425b730771 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -38,7 +38,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index c6828c2c290..bc43a234ffc 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -17,7 +17,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -297,7 +297,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 6daab3f32bb..5f60bc418e3 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -3,26 +3,26 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable -from datetime import datetime, timedelta, timezone +from datetime import timedelta, timezone from http import HTTPMethod from typing import Any from unittest.mock import MagicMock, patch -from aiohttp import ClientSession from evohomeasync2 import EvohomeClient -from evohomeasync2.broker import Broker -from evohomeasync2.controlsystem import ControlSystem +from evohomeasync2.auth import AbstractTokenManager, Auth +from evohomeasync2.control_system import ControlSystem from evohomeasync2.zone import Zone +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN -from homeassistant.const import Platform +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from homeassistant.util.json import JsonArrayType, JsonObjectType -from .const import ACCESS_TOKEN, REFRESH_TOKEN, USERNAME +from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME from tests.common import load_json_array_fixture, load_json_object_fixture @@ -64,44 +64,69 @@ def zone_schedule_fixture(install: str) -> JsonObjectType: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) -def mock_get_factory(install: str) -> Callable: +def mock_post_request(install: str) -> Callable: + """Obtain an access token via a POST to the vendor's web API.""" + + async def post_request( + self: AbstractTokenManager, url: str, /, **kwargs: Any + ) -> JsonArrayType | JsonObjectType: + """Obtain an access token via a POST to the vendor's web API.""" + + if "Token" in url: + return { + "access_token": f"new_{ACCESS_TOKEN}", + "token_type": "bearer", + "expires_in": 1800, + "refresh_token": f"new_{REFRESH_TOKEN}", + # "scope": "EMEA-V1-Basic EMEA-V1-Anonymous", # optional + } + + if "session" in url: + return {"sessionId": f"new_{SESSION_ID}"} + + pytest.fail(f"Unexpected request: {HTTPMethod.POST} {url}") + + return post_request + + +def mock_make_request(install: str) -> Callable: """Return a get method for a specified installation.""" - async def mock_get( - self: Broker, url: str, **kwargs: Any + async def make_request( + self: Auth, method: HTTPMethod, url: str, **kwargs: Any ) -> JsonArrayType | JsonObjectType: """Return the JSON for a HTTP get of a given URL.""" - # a proxy for the behaviour of the real web API - if self.refresh_token is None: - self.refresh_token = f"new_{REFRESH_TOKEN}" + if method != HTTPMethod.GET: + pytest.fail(f"Unmocked method: {method} {url}") - if ( - self.access_token_expires is None - or self.access_token_expires < datetime.now() - ): - self.access_token = f"new_{ACCESS_TOKEN}" - self.access_token_expires = datetime.now() + timedelta(minutes=30) + await self._headers() # assume a valid GET, and return the JSON for that web API - if url == "userAccount": # userAccount + if url == "accountInfo": # /v0/accountInfo + return {} # will throw a KeyError -> BadApiResponseError + + if url.startswith("locations/"): # /v0/locations?userId={id}&allData=True + return [] # user has no locations + + if url == "userAccount": # /v2/userAccount return user_account_config_fixture(install) - if url.startswith("location"): - if "installationInfo" in url: # location/installationInfo?userId={id} + if url.startswith("location/"): + if "installationInfo" in url: # /v2/location/installationInfo?userId={id} return user_locations_config_fixture(install) - if "location" in url: # location/{id}/status + if "status" in url: # /v2/location/{id}/status return location_status_fixture(install) elif "schedule" in url: - if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule + if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule return dhw_schedule_fixture(install) - if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule + if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule return zone_schedule_fixture(install) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") - return mock_get + return make_request @pytest.fixture @@ -137,9 +162,13 @@ async def setup_evohome( dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset))) with ( - patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, - patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), - patch("evohomeasync2.broker.Broker.get", mock_get_factory(install)), + # patch("homeassistant.components.evohome.ec1.EvohomeClient", return_value=None), + patch("homeassistant.components.evohome.ec2.EvohomeClient") as mock_client, + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request(install), + ), + patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)), ): evo: EvohomeClient | None = None @@ -155,12 +184,11 @@ async def setup_evohome( mock_client.assert_called_once() - assert mock_client.call_args.args[0] == config[CONF_USERNAME] - assert mock_client.call_args.args[1] == config[CONF_PASSWORD] + assert isinstance(evo, EvohomeClient) + assert evo._token_manager.client_id == config[CONF_USERNAME] + assert evo._token_manager._secret == config[CONF_PASSWORD] - assert isinstance(mock_client.call_args.kwargs["session"], ClientSession) - - assert evo and evo.account_info is not None + assert evo.user_account mock_client.return_value = evo yield mock_client @@ -170,39 +198,32 @@ async def setup_evohome( async def evohome( hass: HomeAssistant, config: dict[str, str], + freezer: FrozenDateTimeFactory, install: str, ) -> AsyncGenerator[MagicMock]: """Return the mocked evohome client for this install fixture.""" + freezer.move_to("2024-07-10T12:00:00Z") # so schedules are as expected + async for mock_client in setup_evohome(hass, config, install=install): yield mock_client @pytest.fixture -async def ctl_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def ctl_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's controller.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - ctl: ControlSystem = evo._get_single_tcs() + evo: EvohomeClient = evohome.return_value + ctl: ControlSystem = evo.tcs - yield f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" + return f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" @pytest.fixture -async def zone_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def zone_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's first zone.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - zone: Zone = list(evo._get_single_tcs().zones.values())[0] + evo: EvohomeClient = evohome.return_value + zone: Zone = evo.tcs.zones[0] - yield f"{Platform.CLIMATE}.{slugify(zone.name)}" + return f"{Platform.CLIMATE}.{slugify(zone.name)}" diff --git a/tests/components/evohome/fixtures/h032585/user_locations.json b/tests/components/evohome/fixtures/h032585/user_locations.json index b4ea2e5c420..c291d591c99 100644 --- a/tests/components/evohome/fixtures/h032585/user_locations.json +++ b/tests/components/evohome/fixtures/h032585/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "GMTStandardTime", "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", diff --git a/tests/components/evohome/fixtures/h099625/user_locations.json b/tests/components/evohome/fixtures/h099625/user_locations.json index cc32caccc73..31cac00ae9e 100644 --- a/tests/components/evohome/fixtures/h099625/user_locations.json +++ b/tests/components/evohome/fixtures/h099625/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "FLEStandardTime", "displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index ce7fcf2744e..23a15e3f64f 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -2,120 +2,120 @@ # name: test_ctl_set_hvac_mode[default] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h032585] list([ tuple( - 'Off', + , ), tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h099625] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[sys_004] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_off[default] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[h032585] list([ tuple( - 'Off', + , ), ]) # --- # name: test_ctl_turn_off[h099625] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[minimal] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[sys_004] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_on[default] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[h032585] list([ tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_turn_on[h099625] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[minimal] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[sys_004] list([ tuple( - 'Auto', + , ), ]) # --- @@ -137,16 +137,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -184,16 +184,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -230,21 +230,21 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20', + 'fault_type': 'TempZoneActuatorLowBattery', + 'since': '2022-03-02T04:50:20+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -282,16 +282,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -329,16 +329,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -376,16 +376,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -423,20 +423,20 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01', + 'fault_type': 'TempZoneActuatorCommunicationLost', + 'since': '2022-03-02T15:56:01+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -477,8 +477,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -513,16 +513,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -560,16 +560,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -606,17 +606,17 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -654,16 +654,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -701,16 +701,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -748,16 +748,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -795,16 +795,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -845,8 +845,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -881,16 +881,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 14.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -923,8 +923,8 @@ 'max_temp': 35, 'min_temp': 7, 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '416856', 'system_mode_status': dict({ 'is_permanent': True, @@ -959,16 +959,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1006,8 +1006,8 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '8557535', 'system_mode_status': dict({ 'is_permanent': True, @@ -1042,16 +1042,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1089,16 +1089,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1136,16 +1136,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1186,8 +1186,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -1222,8 +1222,12 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + dict({ + 'fault_type': 'GatewayCommunicationLost', + 'since': '2023-05-04T18:47:36.772704+02:00', + }), + ), 'system_id': '4187769', 'system_mode_status': dict({ 'is_permanent': True, @@ -1258,16 +1262,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 15.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+02:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+02:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1331,7 +1335,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1344,7 +1348,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1357,7 +1361,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1370,7 +1374,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1383,35 +1387,35 @@ 15.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h032585] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h099625] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[minimal] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 4cdeb28f445..771e2c20cba 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -13,11 +13,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -25,13 +25,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ @@ -60,11 +60,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -72,13 +72,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 325dd914bc0..b1b930c6382 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -65,7 +65,7 @@ async def test_ctl_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -76,14 +76,15 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -94,11 +95,12 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -134,7 +136,7 @@ async def test_ctl_turn_off( results = [] # SERVICE_TURN_OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_OFF, @@ -144,11 +146,12 @@ async def test_ctl_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -164,7 +167,7 @@ async def test_ctl_turn_on( results = [] # SERVICE_TURN_ON - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -174,11 +177,12 @@ async def test_ctl_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -194,7 +198,7 @@ async def test_zone_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -205,9 +209,7 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_HVAC_MODE: HVACMode.OFF with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -221,7 +223,9 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -243,7 +247,7 @@ async def test_zone_set_preset_mode( results = [] # SERVICE_SET_PRESET_MODE: none - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_PRESET_MODE, @@ -254,9 +258,7 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_PRESET_MODE: permanent with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -270,7 +272,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -288,7 +292,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -302,12 +308,10 @@ async def test_zone_set_preset_mode( async def test_zone_set_temperature( hass: HomeAssistant, zone_id: str, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test SERVICE_SET_TEMPERATURE of an evohome heating zone.""" - freezer.move_to("2024-07-10T12:00:00Z") results = [] # SERVICE_SET_TEMPERATURE: temperature @@ -322,7 +326,9 @@ async def test_zone_set_temperature( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == (19.1,) assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -352,7 +358,9 @@ async def test_zone_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -369,7 +377,7 @@ async def test_zone_turn_on( """Test SERVICE_TURN_ON of an evohome heating zone.""" # SERVICE_TURN_ON - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -379,6 +387,4 @@ async def test_zone_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() diff --git a/tests/components/evohome/test_coordinator.py b/tests/components/evohome/test_coordinator.py new file mode 100644 index 00000000000..7fb325d55b9 --- /dev/null +++ b/tests/components/evohome/test_coordinator.py @@ -0,0 +1,55 @@ +"""The tests for the evohome coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome import EvoData +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize("install", ["minimal"]) +async def test_setup_platform( + hass: HomeAssistant, + config: dict[str, str], + evohome: EvohomeClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities and their states after setup of evohome.""" + + evo_data: EvoData = hass.data.get(DOMAIN) # type: ignore[assignment] + update_interval: timedelta = evo_data.coordinator.update_interval # type: ignore[assignment] + + # confirm initial state after coordinator.async_first_refresh()... + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE + + with patch( + "homeassistant.components.evohome.coordinator.EvoDataUpdateCoordinator._async_update_data", + side_effect=UpdateFailed, + ): + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # confirm appropriate response to loss of state... + state = hass.states.get("climate.my_home") + assert state is not None and state.state == STATE_UNAVAILABLE + + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # if coordinator is working, the state will be restored + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE diff --git a/tests/components/evohome/test_evo_services.py b/tests/components/evohome/test_evo_services.py new file mode 100644 index 00000000000..c9f20aecd4f --- /dev/null +++ b/tests/components/evohome/test_evo_services.py @@ -0,0 +1,177 @@ +"""The tests for the native services of Evohome.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome.const import ( + ATTR_DURATION, + ATTR_PERIOD, + ATTR_SETPOINT, + DOMAIN, + EvoService, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_refresh_system( + hass: HomeAssistant, + evohome: EvohomeClient, +) -> None: + """Test Evohome's refresh_system service (for all temperature control systems).""" + + # EvoService.REFRESH_SYSTEM + with patch("evohomeasync2.location.Location.update") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.REFRESH_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_reset_system( + hass: HomeAssistant, + ctl_id: str, +) -> None: + """Test Evohome's reset_system service (for a temperature control system).""" + + # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_ctl_set_system_mode( + hass: HomeAssistant, + ctl_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_system_mode service (for a temperature control system).""" + + # EvoService.SET_SYSTEM_MODE: Auto + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Auto", + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("Auto", until=None) + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoService.SET_SYSTEM_MODE: AutoWithEco, hours=12 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "AutoWithEco", + ATTR_DURATION: {"hours": 12}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "AutoWithEco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC) + ) + + # EvoService.SET_SYSTEM_MODE: Away, days=7 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Away", + ATTR_PERIOD: {"days": 7}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + ) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_clear_zone_override( + hass: HomeAssistant, + zone_id: str, +) -> None: + """Test Evohome's clear_zone_override service (for a heating zone).""" + + # EvoZoneMode.FOLLOW_SCHEDULE + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_set_zone_override( + hass: HomeAssistant, + zone_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_zone_override service (for a heating zone).""" + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoZoneMode.PERMANENT_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with(19.5, until=None) + + # EvoZoneMode.TEMPORARY_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + ATTR_DURATION: {"minutes": 135}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + 19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC) + ) diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 49a854016ea..53b9258523d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -1,83 +1,133 @@ -"""The tests for evohome.""" +"""The tests for Evohome.""" from __future__ import annotations from http import HTTPStatus import logging -from unittest.mock import patch +from unittest.mock import Mock, patch +import aiohttp from evohomeasync2 import EvohomeClient, exceptions as exc -from evohomeasync2.broker import _ERR_MSG_LOOKUP_AUTH, _ERR_MSG_LOOKUP_BASE import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import mock_post_request from .const import TEST_INSTALLS -SETUP_FAILED_ANTICIPATED = ( +_MSG_429 = ( + "You have exceeded the server's API rate limit. Wait a while " + "and try again (consider reducing your polling interval)." +) +_MSG_OTH = ( + "Unable to contact the vendor's server. Check your network " + "and review the vendor's status page, https://status.resideo.com." +) +_MSG_USR = ( + "Failed to authenticate. Check the username/password. Note that some " + "special characters accepted via the vendor's website are not valid here." +) + +LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429) +LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR) + +LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429) +LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR) + +LOG_FAIL_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: Authenticator response is invalid: Connection error", +) +LOG_FAIL_CREDENTIALS = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: {'error': 'invalid_grant'}", +) +LOG_FAIL_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 502 Bad Gateway, response=None", +) +LOG_FAIL_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 429 Too Many Requests, response=None", +) + +LOG_FGET_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "Connection error", +) +LOG_FGET_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "502 Bad Gateway, response=None", +) +LOG_FGET_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "429 Too Many Requests, response=None", +) + + +LOG_SETUP_FAILED = ( "homeassistant.setup", logging.ERROR, "Setup failed for 'evohome': Integration failed to initialize.", ) -SETUP_FAILED_UNEXPECTED = ( - "homeassistant.setup", - logging.ERROR, - "Error during setup of component evohome", + +EXC_BAD_CONNECTION = aiohttp.ClientConnectionError( + "Connection error", ) -AUTHENTICATION_FAILED = ( - "homeassistant.components.evohome.helpers", - logging.ERROR, - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: ", +EXC_BAD_CREDENTIALS = exc.AuthenticationFailedError( + "Authenticator response is invalid: {'error': 'invalid_grant'}", + status=HTTPStatus.BAD_REQUEST, ) -REQUEST_FAILED_NONE = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: ", +EXC_TOO_MANY_REQUESTS = aiohttp.ClientResponseError( + Mock(), + (), + status=HTTPStatus.TOO_MANY_REQUESTS, + message=HTTPStatus.TOO_MANY_REQUESTS.phrase, ) -REQUEST_FAILED_503 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page", -) -REQUEST_FAILED_429 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the scan_interval", +EXC_BAD_GATEWAY = aiohttp.ClientResponseError( + Mock(), (), status=HTTPStatus.BAD_GATEWAY, message=HTTPStatus.BAD_GATEWAY.phrase ) -REQUEST_FAILED_LOOKUP = { - None: [ - REQUEST_FAILED_NONE, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.SERVICE_UNAVAILABLE: [ - REQUEST_FAILED_503, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.TOO_MANY_REQUESTS: [ - REQUEST_FAILED_429, - SETUP_FAILED_ANTICIPATED, - ], +AUTHENTICATION_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED], +} + +CLIENT_REQUEST_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FGET_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FGET_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FGET_TOO_MANY, LOG_SETUP_FAILED], } -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_AUTH, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", AUTHENTICATION_TESTS) async def test_authentication_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -85,27 +135,24 @@ async def test_authentication_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.AuthenticationFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohome.credentials.CredentialsManagerBase._request", side_effect=exception + ), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == [ - AUTHENTICATION_FAILED, - SETUP_FAILED_ANTICIPATED, - ] + assert caplog.record_tuples == AUTHENTICATION_TESTS[exception] -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_BASE, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", CLIENT_REQUEST_TESTS) async def test_client_request_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -113,17 +160,19 @@ async def test_client_request_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.RequestFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request("default"), + ), + patch("evohome.auth.AbstractAuth._request", side_effect=exception), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == REQUEST_FAILED_LOOKUP.get( - status, [SETUP_FAILED_UNEXPECTED] - ) + assert caplog.record_tuples == CLIENT_REQUEST_TESTS[exception] @pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"]) @@ -138,45 +187,3 @@ async def test_setup( """ assert hass.services.async_services_for_domain(DOMAIN).keys() == snapshot - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_refresh_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.REFRESH_SYSTEM of an evohome system.""" - - # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.refresh_status") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.REFRESH_SYSTEM, - {}, - blocking=True, - ) - - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_reset_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.RESET_SYSTEM of an evohome system.""" - - # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.RESET_SYSTEM, - {}, - blocking=True, - ) - - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == ("AutoWithReset",) - assert mock_fcn.await_args.kwargs == {"until": None} diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 4cc21078333..4528f1c8590 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -7,22 +7,17 @@ from typing import Any, Final, NotRequired, TypedDict import pytest -from homeassistant.components.evohome import ( - CONF_USERNAME, - DOMAIN, - STORAGE_KEY, - STORAGE_VER, - dt_aware_to_naive, -) +from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import setup_evohome from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME class _SessionDataT(TypedDict): - sessionId: str + session_id: str + session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00 class _TokenStoreT(TypedDict): @@ -65,7 +60,7 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": _TEST_STORAGE_BASE, "null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] - "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"sessionId": SESSION_ID}}, + "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"session_id": SESSION_ID}}, } TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { @@ -89,15 +84,12 @@ async def test_auth_tokens_null( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when no cached tokens in the store.""" + """Test credentials manager when cache is empty.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated without tokens, as cache was empty... - assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -120,17 +112,12 @@ async def test_auth_tokens_same( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when matching username.""" + """Test credentials manager when cache contains valid data for this user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated with the cached tokens... - assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN - assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert mock_client.call_args.kwargs[ - SZ_ACCESS_TOKEN_EXPIRES - ] == dt_aware_to_naive(ACCESS_TOKEN_EXP_DTM) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -150,7 +137,7 @@ async def test_auth_tokens_past( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens with matching username, but expired.""" + """Test credentials manager when cache contains expired data for this user.""" dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) @@ -160,19 +147,14 @@ async def test_auth_tokens_past( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated with the cached tokens... - assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN - assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert mock_client.call_args.kwargs[ - SZ_ACCESS_TOKEN_EXPIRES - ] == dt_aware_to_naive(dt_dtm) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] assert data[SZ_USERNAME] == USERNAME_SAME - assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) @@ -189,17 +171,13 @@ async def test_auth_tokens_diff( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when unmatched username.""" + """Test credentials manager when cache contains data for a different user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} + config["username"] = USERNAME_DIFF - async for mock_client in setup_evohome( - hass, config | {CONF_USERNAME: USERNAME_DIFF}, install=install - ): - # Confirm client was instantiated without tokens, as username was different... - assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index 8acfd469b59..a201ff63d1e 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -67,7 +67,7 @@ async def test_set_operation_mode( results = [] # SERVICE_SET_OPERATION_MODE: auto - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -78,12 +78,10 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -94,14 +92,16 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} results.append(mock_fcn.await_args.kwargs) # SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -112,7 +112,9 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} @@ -126,7 +128,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone.""" # set_away_mode: off - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -137,12 +139,10 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # set_away_mode: on - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -153,9 +153,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 78bbee0b0ad..1d4911e9785 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -1,102 +1,13 @@ """Tests for the EZVIZ integration.""" -from unittest.mock import _patch, patch - -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, -) -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, -) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -ENTRY_CONFIG = { - CONF_SESSION_ID: "test-username", - CONF_RFSESSION_ID: "test-password", - CONF_URL: "apiieu.ezvizlife.com", - CONF_TYPE: ATTR_TYPE_CLOUD, -} -ENTRY_OPTIONS = { - CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, - CONF_TIMEOUT: DEFAULT_TIMEOUT, -} - -USER_INPUT_VALIDATE = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_URL: "apiieu.ezvizlife.com", -} - -USER_INPUT = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_URL: "apiieu.ezvizlife.com", - 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, - CONF_PASSWORD: None, - CONF_IP_ADDRESS: "127.0.0.1", -} - -TEST = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - 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() -> _patch: - """Patch async_setup_entry.""" - return patch( - "homeassistant.components.ezviz.async_setup_entry", - return_value=True, - ) - - -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Set up the EZVIZ integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - - return entry diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 171cfffc2fc..fab8111b171 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -1,19 +1,30 @@ """Define pytest.fixtures available for all tests.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pyezviz import EzvizClient -from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +from homeassistant.components.ezviz import ( + ATTR_TYPE_CAMERA, + ATTR_TYPE_CLOUD, + CONF_RFSESSION_ID, + CONF_SESSION_ID, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_TYPE, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -ezviz_login_token_return = { - "session_id": "fake_token", - "rf_session_id": "fake_rf_token", - "api_url": "apiieu.ezvizlife.com", -} +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.ezviz.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock @pytest.fixture(autouse=True) @@ -23,40 +34,67 @@ def mock_ffmpeg(hass: HomeAssistant) -> None: @pytest.fixture -def ezviz_test_rtsp_config_flow() -> Generator[MagicMock]: +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + title="test-username", + data={ + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + }, + ) + + +@pytest.fixture +def mock_camera_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="C666666", + title="Camera 1", + data={ + CONF_TYPE: ATTR_TYPE_CAMERA, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + +@pytest.fixture +def mock_ezviz_client() -> Generator[AsyncMock]: + """Mock the EzvizAPI for easier testing.""" + with ( + patch( + "homeassistant.components.ezviz.EzvizClient", autospec=True + ) as mock_ezviz, + patch("homeassistant.components.ezviz.config_flow.EzvizClient", new=mock_ezviz), + ): + instance = mock_ezviz.return_value + + instance.login.return_value = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", + } + instance.get_detection_sensibility.return_value = True + + yield instance + + +@pytest.fixture +def mock_test_rtsp_auth() -> Generator[MagicMock]: """Mock the EzvizApi for easier testing.""" with ( - patch.object(TestRTSPAuth, "main", return_value=True), patch( "homeassistant.components.ezviz.config_flow.TestRTSPAuth" ) as mock_ezviz_test_rtsp, ): - instance = mock_ezviz_test_rtsp.return_value = TestRTSPAuth( - "test-ip", - "test-username", - "test-password", - ) + instance = mock_ezviz_test_rtsp.return_value - instance.main = MagicMock(return_value=True) + instance.main.return_value = True - yield mock_ezviz_test_rtsp - - -@pytest.fixture -def ezviz_config_flow() -> Generator[MagicMock]: - """Mock the EzvizAPI for easier config flow testing.""" - with ( - patch.object(EzvizClient, "login", return_value=True), - patch("homeassistant.components.ezviz.config_flow.EzvizClient") as mock_ezviz, - ): - instance = mock_ezviz.return_value = EzvizClient( - "test-username", - "test-password", - "local.host", - "1", - ) - - instance.login = MagicMock(return_value=ezviz_login_token_return) - instance.get_detection_sensibility = MagicMock(return_value=True) - - yield mock_ezviz + yield instance diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 63499996c89..ff538b31edb 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -1,11 +1,9 @@ """Test the EZVIZ config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock from pyezviz.exceptions import ( - AuthTestResultFailed, EzvizAuthVerificationCode, - HTTPError, InvalidHost, InvalidURL, PyEzvizError, @@ -15,7 +13,10 @@ import pytest 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, @@ -33,20 +34,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ( - API_LOGIN_RETURN_VALIDATE, - DISCOVERY_INFO, - USER_INPUT_VALIDATE, - init_integration, - patch_async_setup_entry, -) +from . import setup_integration -from tests.common import MockConfigEntry, start_reauth_flow +from tests.common import MockConfigEntry -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_user_form(hass: HomeAssistant) -> None: - """Test the user initiated form.""" +@pytest.mark.usefixtures("mock_ezviz_client") +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -55,28 +50,32 @@ async def test_user_form(hass: HomeAssistant) -> None: 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() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" assert len(mock_setup_entry.mock_calls) == 1 - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured_account" - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_user_custom_url(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client") +async def test_user_custom_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test custom url step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -95,45 +94,30 @@ async def test_user_custom_url(hass: HomeAssistant) -> None: assert result["step_id"] == "user_custom_url" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == API_LOGIN_RETURN_VALIDATE - - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_async_step_reauth(hass: HomeAssistant) -> None: - """Test the reauth step.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, ) - assert result["type"] is 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"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } assert len(mock_setup_entry.mock_calls) == 1 - new_entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await start_reauth_flow(hass, new_entry) + +@pytest.mark.usefixtures("mock_ezviz_client", "mock_setup_entry") +async def test_async_step_reauth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -145,19 +129,26 @@ async def test_async_step_reauth(hass: HomeAssistant) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("mock_ezviz_client") async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, + hass: HomeAssistant, mock_test_rtsp_auth: AsyncMock ) -> None: """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY}, data=DISCOVERY_INFO + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" @@ -170,45 +161,52 @@ async def test_step_discovery_abort_if_cloud_account_missing( CONF_PASSWORD: "test-pass", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ezviz_cloud_account_missing" -async def test_step_reauth_abort_if_cloud_account_missing(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client", "mock_test_rtsp_auth") +async def test_step_reauth_abort_if_cloud_account_missing( + hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry +) -> None: """Test reauth and confirm step, abort if cloud account was removed.""" - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT_VALIDATE) - entry.add_to_hass(hass) + mock_camera_config_entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) + result = await mock_camera_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ezviz_cloud_account_missing" -@pytest.mark.usefixtures("ezviz_config_flow", "ezviz_test_rtsp_config_flow") -async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_ezviz_client", "mock_test_rtsp_auth", "mock_setup_entry") +async def test_async_step_integration_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_INTEGRATION_DISCOVERY}, data=DISCOVERY_INFO + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" assert result["errors"] == {} - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -216,40 +214,103 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: CONF_TYPE: ATTR_TYPE_CAMERA, CONF_USERNAME: "test-user", } - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "C666666" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test updating options.""" - with patch_async_setup_entry() as mock_setup_entry: - entry = await init_integration(hass) + await setup_integration(hass, mock_config_entry) - assert entry.options[CONF_FFMPEG_ARGUMENTS] == DEFAULT_FFMPEG_ARGUMENTS - assert entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT + assert mock_config_entry.options[CONF_FFMPEG_ARGUMENTS] == DEFAULT_FFMPEG_ARGUMENTS + assert mock_config_entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_FFMPEG_ARGUMENTS: "/H.264", CONF_TIMEOUT: 25}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_FFMPEG_ARGUMENTS: "/H.264", CONF_TIMEOUT: 25}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_FFMPEG_ARGUMENTS] == "/H.264" assert result["data"][CONF_TIMEOUT] == 25 + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "cannot_connect"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test the full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" + assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_form_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_unknown_exception( + hass: HomeAssistant, mock_ezviz_client: AsyncMock ) -> None: - """Test we handle exception on user form.""" + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -257,223 +318,53 @@ async def test_user_form_exception( assert result["step_id"] == "user" assert result["errors"] == {} - ezviz_config_flow.side_effect = PyEzvizError + mock_ezviz_client.login.side_effect = Exception result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = InvalidURL - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - 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"] is 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( - result["flow_id"], - USER_INPUT_VALIDATE, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT_VALIDATE, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: "apiieu.ezvizlife.com", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" -async def test_discover_exception_step1( +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "cannot_connect"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_user_custom_url_errors( hass: HomeAssistant, - ezviz_config_flow: MagicMock, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, ) -> None: - """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) + """Test the full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data={ATTR_SERIAL: "C66666", CONF_IP_ADDRESS: "test-ip"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {} - - # Test Step 1 - ezviz_config_flow.side_effect = PyEzvizError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_config_flow.side_effect = InvalidURL - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - 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"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -@pytest.mark.usefixtures("ezviz_config_flow") -async def test_discover_exception_step3( - hass: HomeAssistant, ezviz_test_rtsp_config_flow: MagicMock -) -> None: - """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): - await init_integration(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data={ATTR_SERIAL: "C66666", CONF_IP_ADDRESS: "test-ip"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {} - - # Test Step 3 - ezviz_test_rtsp_config_flow.side_effect = AuthTestResultFailed - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_auth"} - - ezviz_test_rtsp_config_flow.side_effect = InvalidHost - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} - - ezviz_test_rtsp_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_user_custom_url_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock -) -> None: - """Test we handle unexpected exception.""" - ezviz_config_flow.side_effect = PyEzvizError() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-pass", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", CONF_URL: CONF_CUSTOMIZE, }, ) @@ -489,56 +380,33 @@ async def test_user_custom_url_exception( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": error} - ezviz_config_flow.side_effect = InvalidURL + mock_ezviz_client.login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_URL: "test-user"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "invalid_host"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, + } + assert result["result"].unique_id == "test-username" - ezviz_config_flow.side_effect = HTTPError - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - 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"] is FlowResultType.FORM - assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "mfa_required"} - - ezviz_config_flow.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "test-user"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_reauth_exception( - hass: HomeAssistant, ezviz_config_flow: MagicMock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_custom_url_unknown_exception( + hass: HomeAssistant, mock_ezviz_client: AsyncMock ) -> None: - """Test the reauth step exceptions.""" + """Test the full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -547,26 +415,210 @@ async def test_async_step_reauth_exception( 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() + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_already_configured( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow when the account is already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_account" + + +async def test_async_step_integration_discovery_duplicate( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_camera_config_entry: MockConfigEntry, +) -> None: + """Test discovery and confirm step.""" + mock_config_entry.add_to_hass(hass) + mock_camera_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "invalid_host"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_camera_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test the camera flow with errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + assert result["title"] == "C666666" + assert result["data"] == { + CONF_TYPE: ATTR_TYPE_CAMERA, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "C666666" - assert len(mock_setup_entry.mock_calls) == 1 - new_entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await start_reauth_flow(hass, new_entry) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_camera_unknown_error( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_test_rtsp_auth: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the camera flow with errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={ + ATTR_SERIAL: "C666666", + CONF_USERNAME: None, + CONF_PASSWORD: None, + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidURL, "invalid_host"), + (InvalidHost, "invalid_host"), + (EzvizAuthVerificationCode, "mfa_required"), + (PyEzvizError, "invalid_auth"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - ezviz_config_flow.side_effect = InvalidURL() + mock_ezviz_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -574,13 +626,12 @@ async def test_async_step_reauth_exception( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": error} + + mock_ezviz_client.login.side_effect = None - ezviz_config_flow.side_effect = InvalidHost() result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -588,49 +639,33 @@ async def test_async_step_reauth_exception( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() - - assert result["type"] is 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"] is 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"] is 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"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_unknown_exception( + hass: HomeAssistant, + mock_ezviz_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth step.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + mock_ezviz_client.login.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index 77ae544646d..db9cd86e086 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -5,7 +5,7 @@ from http import HTTPStatus import pytest import requests_mock -import homeassistant.components.facebook.notify as fb +from homeassistant.components.facebook import notify as fb from homeassistant.core import HomeAssistant diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f4673636637..bef44c92f34 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 90061ec60a1..0ab7686a68b 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import MockFan diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 32f8ecb8080..8f5f3870bfe 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -13,7 +13,7 @@ from homeassistant.components.feedreader.event import ( ATTR_TITLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import create_mock_entry from .const import VALID_CONFIG_DEFAULT diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 9a2575bf591..5d2ac1a4406 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.feedreader.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_setup_config_entry, create_mock_entry from .const import ( diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 583c44a41e6..55b7e35132c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -157,12 +157,31 @@ def mock_thermostat() -> Mock: return climate +@pytest.fixture +def mock_thermostat_parent() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 5 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.device" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = [] + return climate + + @pytest.fixture def mock_thermostat_with_operating_mode() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 4 - climate.parent_fibaro_id = 0 + climate.fibaro_id = 6 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 climate.name = "Test climate" climate.room_id = 1 climate.dead = False @@ -171,20 +190,47 @@ def mock_thermostat_with_operating_mode() -> Mock: climate.type = "com.fibaro.thermostatDanfoss" climate.base_type = "com.fibaro.device" climate.properties = {"manufacturer": ""} - climate.actions = {"setOperationMode": 1} + climate.actions = {"setOperatingMode": 1, "setTargetLevel": 1} climate.supported_features = {} climate.has_supported_operating_modes = True climate.supported_operating_modes = [0, 1, 15] climate.has_operating_mode = True climate.operating_mode = 15 + climate.has_supported_thermostat_modes = False climate.has_thermostat_mode = False + climate.has_unit = True + climate.unit = "C" + climate.has_heating_thermostat_setpoint = False + climate.has_heating_thermostat_setpoint_future = False + climate.target_level = 23 value_mock = Mock() value_mock.has_value = True - value_mock.int_value.return_value = 20 + value_mock.float_value.return_value = 20 climate.value = value_mock return climate +@pytest.fixture +def mock_fan_device() -> Mock: + """Fixture for a fan endpoint of a thermostat device.""" + climate = Mock() + climate.fibaro_id = 7 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 + climate.name = "Test fan" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.fan" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setFanMode": 1} + climate.supported_modes = [0, 1, 2] + climate.mode = 1 + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -209,19 +255,22 @@ def mock_fibaro_client() -> Generator[Mock]: info_mock.hc_name = TEST_NAME info_mock.current_version = TEST_VERSION info_mock.platform = TEST_MODEL + info_mock.manufacturer_name = "Fibaro" + info_mock.model_name = "Home Center 2" + info_mock.mac_address = "00:22:4d:b7:13:24" with patch( "homeassistant.components.fibaro.FibaroClient", autospec=True ) as fibaro_client_mock: client = fibaro_client_mock.return_value - client.set_authentication.return_value = None - client.connect.return_value = True + client.connect_with_credentials.return_value = info_mock client.read_info.return_value = info_mock client.read_rooms.return_value = [] client.read_scenes.return_value = [] client.read_devices.return_value = [] client.register_update_handler.return_value = None client.unregister_update_handler.return_value = None + client.frontend_url.return_value = TEST_URL.removesuffix("/api/") yield client diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py index 31022e19a08..339d9d23077 100644 --- a/tests/components/fibaro/test_climate.py +++ b/tests/components/fibaro/test_climate.py @@ -130,5 +130,153 @@ async def test_hvac_mode_with_operation_mode_support( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_6") assert state.state == HVACMode.AUTO + + +async def test_set_hvac_mode_with_operation_mode_support( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_with_operating_mode: Mock, + mock_room: Mock, +) -> None: + """Test that set_hvac_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat_with_operating_mode] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_6", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() + + +async def test_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["fan_mode"] == "low" + assert state.attributes["fan_modes"] == ["off", "low", "auto_high"] + + +async def test_set_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.room_1_test_climate_6", "fan_mode": "off"}, + blocking=True, + ) + + # Assert + mock_fan_device.execute_action.assert_called_once() + + +async def test_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["temperature"] == 23 + + +async def test_set_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": "climate.room_1_test_climate_6", "temperature": 25.5}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 508bb81973d..aee7c2eb903 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -2,8 +2,8 @@ from unittest.mock import Mock +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed import pytest -from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components.fibaro import DOMAIN @@ -23,8 +23,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") async def _recovery_after_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -48,8 +50,10 @@ async def _recovery_after_failure_works( async def _recovery_after_reauth_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -101,7 +105,9 @@ async def test_config_flow_user_initiated_auth_failure( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -119,7 +125,7 @@ async def test_config_flow_user_initiated_auth_failure( await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_unknown_failure_1( +async def test_config_flow_user_initiated_connect_failure( hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" @@ -131,37 +137,7 @@ async def test_config_flow_user_initiated_unknown_failure_1( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - await _recovery_after_failure_works(hass, mock_fibaro_client, result) - - -async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, mock_fibaro_client: Mock -) -> None: - """Unknown failure in flow manually initialized by the user.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -208,7 +184,7 @@ async def test_reauth_connect_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -233,7 +209,9 @@ async def test_reauth_auth_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index d0a24e009b7..88576e86dc6 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -2,7 +2,8 @@ from unittest.mock import Mock, patch -from homeassistant.const import Platform +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -55,3 +56,28 @@ async def test_light_brightness( state = hass.states.get("light.room_1_test_light_3") assert state.attributes["brightness"] == 51 assert state.state == "on" + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test activate scene is called.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.room_1_test_light_3"}, + blocking=True, + ) + # Assert + assert mock_light.execute_action.call_count == 1 diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index e7cb85a9cfc..44b9d61efec 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -12,7 +12,7 @@ from homeassistant.components.file import DOMAIN from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/filesize/snapshots/test_sensor.ambr b/tests/components/filesize/snapshots/test_sensor.ambr index 339d64acf91..e7f6f9d042b 100644 --- a/tests/components/filesize/snapshots/test_sensor.ambr +++ b/tests/components/filesize/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py new file mode 100644 index 00000000000..a576a2edb37 --- /dev/null +++ b/tests/components/filter/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the Filter integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_WINDOW_SIZE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_OUTLIER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant, State +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="values") +def values_fixture() -> list[State]: + """Fixture for a list of test States.""" + values = [] + raw_values = [20, 19, 18, 21, 22, 0] + timestamp = dt_util.utcnow() + for val in raw_values: + values.append(State("sensor.test_monitored", str(val), last_updated=timestamp)) + timestamp += timedelta(minutes=1) + return values + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch setup_entry.""" + with patch( + "homeassistant.components.filter.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 configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any], values: list[State] +) -> MockConfigEntry: + """Set up the Filter integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + for value in values: + hass.states.async_set(get_config["entity_id"], value.state) + await hass.async_block_till_done() + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/filter/test_config_flow.py b/tests/components/filter/test_config_flow.py new file mode 100644 index 00000000000..d4a7f7a854f --- /dev/null +++ b/tests/components/filter/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Filter config flow.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.filter.const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) +from homeassistant.components.recorder import Recorder +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entry_config", "options", "result_options"), + [ + ( + {CONF_FILTER_NAME: FILTER_NAME_OUTLIER}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_RADIUS: 2.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_RADIUS: 2.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_LOWPASS}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_TIME_CONSTANT: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_LOWPASS, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_TIME_CONSTANT: 10, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_RANGE}, + { + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_RANGE, + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_SMA}, + { + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: 1, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ], +) +async def test_form( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + entry_config: dict[str, Any], + options: dict[str, Any], + result_options: dict[str, Any], +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + **entry_config, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILTER_PRECISION: DEFAULT_PRECISION, **options}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + **result_options, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "outlier" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_FILTER_WINDOW_SIZE: 2.0, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 2, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + state = hass.states.get("sensor.filtered_sensor") + assert state is not None + + +async def test_entry_already_exist( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/filter/test_init.py b/tests/components/filter/test_init.py new file mode 100644 index 00000000000..a5d5cf84a67 --- /dev/null +++ b/tests/components/filter/test_init.py @@ -0,0 +1,20 @@ +"""Test Filter component setup process.""" + +from __future__ import annotations + +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a..22db1c3cec2 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -6,8 +6,18 @@ from unittest.mock import patch import pytest from homeassistant import config as hass_config -from homeassistant.components.filter.sensor import ( +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_NAME, + DEFAULT_PRECISION, DOMAIN, + FILTER_NAME_TIME_SMA, + TIME_SMA_LAST, +) +from homeassistant.components.filter.sensor import ( LowPassFilter, OutlierFilter, RangeFilter, @@ -24,6 +34,8 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -32,9 +44,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, get_fixture_path +from tests.common import MockConfigEntry, assert_setup_component, get_fixture_path @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -97,6 +109,41 @@ async def test_chain( assert state.state == "18.05" +async def test_from_config_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "22.0" + + +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + ], +) +async def test_from_config_entry_duration( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry with duration.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "20.0" + + @pytest.mark.parametrize("missing", [True, False]) async def test_chain_history( recorder_mock: Recorder, diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py index 5555a8d649c..8d150034ec9 100644 --- a/tests/components/fireservicerota/test_config_flow.py +++ b/tests/components/fireservicerota/test_config_flow.py @@ -66,7 +66,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test that invalid credentials throws an error.""" with patch( - "homeassistant.components.fireservicerota.FireServiceRota.request_tokens", + "homeassistant.components.fireservicerota.coordinator.FireServiceRota.request_tokens", side_effect=InvalidAuthError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index 6ce17261bfc..09957fe496f 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -67,6 +67,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]: flexit_bacnet.air_filter_polluted = False flexit_bacnet.air_filter_exchange_interval = 8784 flexit_bacnet.electric_heater = True + flexit_bacnet.fireplace_mode_runtime = 10 # Mock fan setpoints flexit_bacnet.fan_setpoint_extract_air_fire = 56 diff --git a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr index f983d834927..0b45e1f19be 100644 --- a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr index 790c377b1f2..d15fc291a16 100644 --- a/tests/components/flexit_bacnet/snapshots/test_climate.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index 78eefd08345..622ec81e45d 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +243,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +289,64 @@ 'state': '56', }) # --- +# name: test_numbers[number.device_name_fireplace_mode_runtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 360, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.device_name_fireplace_mode_runtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace mode runtime', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_mode_runtime', + 'unique_id': '0000-0001-fireplace_mode_runtime', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.device_name_fireplace_mode_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Fireplace mode runtime', + 'max': 360, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.device_name_fireplace_mode_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- # name: test_numbers[number.device_name_fireplace_supply_fan_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -296,6 +359,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +417,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -410,6 +475,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +533,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -524,6 +591,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr index 2c65bd53a6e..b265a4402dc 100644 --- a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -112,6 +114,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,6 +420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -460,6 +469,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -510,6 +520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -562,6 +573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -612,6 +624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -662,6 +675,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -710,6 +724,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr index d054608f1f7..0e27c2e938a 100644 --- a/tests/components/flexit_bacnet/snapshots/test_switch.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_switches[switch.device_name_cooker_hood_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_cooker_hood_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooker hood mode', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooker_hood_mode', + 'unique_id': '0000-0001-cooker_hood_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_cooker_hood_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Cooker hood mode', + }), + 'context': , + 'entity_id': 'switch.device_name_cooker_hood_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.device_name_electric_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -46,6 +95,54 @@ 'state': 'on', }) # --- +# name: test_switches[switch.device_name_fireplace_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_fireplace_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace mode', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_mode', + 'unique_id': '0000-0001-fireplace_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_fireplace_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Fireplace mode', + }), + 'context': , + 'entity_id': 'switch.device_name_fireplace_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches_implementation[switch.device_name_electric_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 7b0546f60ea..be361541c39 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -1,17 +1,40 @@ """Tests for the Flexit Nordic (BACnet) climate entity.""" +import asyncio from unittest.mock import AsyncMock +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + PRESET_AWAY, + PRESET_HOME, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_MODE_MAP +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms from tests.common import MockConfigEntry, snapshot_platform +ENTITY_ID = "climate.device_name" + async def test_climate_entity( hass: HomeAssistant, @@ -24,3 +47,175 @@ async def test_climate_entity( await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_hvac_preset_mode( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Set preset mode to away + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_PRESET_MODE: PRESET_AWAY, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY + + mock_flexit_bacnet.set_ventilation_mode.assert_called_once_with( + PRESET_TO_VENTILATION_MODE_MAP[PRESET_AWAY] + ) + + # Set preset mode to home + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_PRESET_MODE: PRESET_HOME, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME + + mock_flexit_bacnet.set_ventilation_mode.assert_called_with( + PRESET_TO_VENTILATION_MODE_MAP[PRESET_HOME] + ) + + mock_flexit_bacnet.set_ventilation_mode.side_effect = asyncio.TimeoutError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_PRESET_MODE: PRESET_AWAY, + }, + blocking=True, + ) + + mock_flexit_bacnet.set_ventilation_mode.assert_called_with( + PRESET_TO_VENTILATION_MODE_MAP[PRESET_AWAY] + ) + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_STOP + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + mock_flexit_bacnet.set_ventilation_mode.assert_called_once_with( + VENTILATION_MODE_STOP + ) + + mock_flexit_bacnet.set_ventilation_mode.side_effect = asyncio.TimeoutError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + mock_flexit_bacnet.set_ventilation_mode.assert_called_with(VENTILATION_MODE_STOP) + + +async def test_hvac_action( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hvac_action property.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Simulate electric heater being ON + mock_flexit_bacnet.electric_heater = True + await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + # Simulate electric heater being OFF + mock_flexit_bacnet.electric_heater = False + await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN + + +async def test_set_temperature( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Set ventilation mode to HOME and set temperature to 22.5°C + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 22.5, + }, + blocking=True, + ) + + # Ensure that the correct method was called + mock_flexit_bacnet.set_air_temp_setpoint_home.assert_called_once_with(22.5) + + # Change ventilation mode to AWAY and set temperature + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 18.0, + }, + blocking=True, + ) + + # Ensure that the correct method was called + mock_flexit_bacnet.set_air_temp_setpoint_away.assert_called_once_with(18.0) + + # Test handling of connection errors + mock_flexit_bacnet.set_air_temp_setpoint_away.side_effect = ConnectionError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 20.0, + }, + blocking=True, + ) diff --git a/tests/components/flick_electric/__init__.py b/tests/components/flick_electric/__init__.py index 36936cad047..3632ce204aa 100644 --- a/tests/components/flick_electric/__init__.py +++ b/tests/components/flick_electric/__init__.py @@ -7,15 +7,26 @@ from homeassistant.components.flick_electric.const import ( CONF_SUPPLY_NODE_REF, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry CONF = { - CONF_USERNAME: "test-username", + CONF_USERNAME: "9973debf-963f-49b0-9a73-ba9c3400cbed@anonymised.example.com", CONF_PASSWORD: "test-password", - CONF_ACCOUNT_ID: "1234", - CONF_SUPPLY_NODE_REF: "123", + CONF_ACCOUNT_ID: "134800", + CONF_SUPPLY_NODE_REF: "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef8299", } +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + def _mock_flick_price(): return FlickPrice( { diff --git a/tests/components/flick_electric/conftest.py b/tests/components/flick_electric/conftest.py new file mode 100644 index 00000000000..2abfafab55d --- /dev/null +++ b/tests/components/flick_electric/conftest.py @@ -0,0 +1,105 @@ +"""Flick Electric tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import json_api_doc +from pyflick import FlickPrice +import pytest + +from homeassistant.components.flick_electric.const import CONF_ACCOUNT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from . import CONF + +from tests.common import MockConfigEntry, load_json_value_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="123 Fake Street, Newtown, Wellington 6021", + data={**CONF}, + version=2, + entry_id="974e52a5c0724d17b7ed876dd6ff4bc8", + unique_id=CONF[CONF_ACCOUNT_ID], + ) + + +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock an outdated config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + title=CONF[CONF_USERNAME], + unique_id=CONF[CONF_USERNAME], + version=1, + ) + + +@pytest.fixture +def mock_flick_client() -> Generator[AsyncMock]: + """Mock a Flick Electric client.""" + with ( + patch( + "homeassistant.components.flick_electric.FlickAPI", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI", + new=mock_api, + ), + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + ): + api = mock_api.return_value + + api.getCustomerAccounts.return_value = json_api_doc.deserialize( + load_json_value_fixture("accounts.json", DOMAIN) + ) + api.getPricing.return_value = FlickPrice( + json_api_doc.deserialize( + load_json_value_fixture("rated_period.json", DOMAIN) + ) + ) + + yield api + + +@pytest.fixture +def mock_flick_client_multiple() -> Generator[AsyncMock]: + """Mock a Flick Electric with multiple accounts.""" + with ( + patch( + "homeassistant.components.flick_electric.FlickAPI", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI", + new=mock_api, + ), + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + ): + api = mock_api.return_value + + api.getCustomerAccounts.return_value = json_api_doc.deserialize( + load_json_value_fixture("accounts_multi.json", DOMAIN) + ) + api.getPricing.return_value = FlickPrice( + json_api_doc.deserialize( + load_json_value_fixture("rated_period.json", DOMAIN) + ) + ) + + yield api diff --git a/tests/components/flick_electric/fixtures/accounts.json b/tests/components/flick_electric/fixtures/accounts.json new file mode 100644 index 00000000000..a1c08ecd7c0 --- /dev/null +++ b/tests/components/flick_electric/fixtures/accounts.json @@ -0,0 +1,105 @@ +{ + "data": [ + { + "id": "134800", + "type": "customer_account", + "attributes": { + "account_number": "10123404", + "billing_name": "9973debf-963f-49b0-9a73-Ba9c3400cbed@Anonymised Example", + "billing_email": null, + "address": "123 Fake Street, Newtown, Wellington 6021", + "brand": "flick", + "vulnerability_state": "none", + "medical_dependency": false, + "status": "active", + "start_at": "2023-03-02T00:00:00.000+13:00", + "end_at": null, + "application_id": "5dfc4978-07de-4d18-8ef7-055603805ba6", + "active": true, + "on_join_journey": false, + "placeholder": false + }, + "relationships": { + "user": { + "data": { + "id": "106676", + "type": "customer_user" + } + }, + "sign_up": { + "data": { + "id": "877039", + "type": "customer_sign_up" + } + }, + "main_customer": { + "data": { + "id": "108335", + "type": "customer_customer" + } + }, + "main_consumer": { + "data": { + "id": "108291", + "type": "customer_icp_consumer" + } + }, + "primary_contact": { + "data": { + "id": "121953", + "type": "customer_contact" + } + }, + "default_payment_method": { + "data": { + "id": "602801", + "type": "customer_payment_method" + } + }, + "phone_numbers": { + "data": [ + { + "id": "111604", + "type": "customer_phone_number" + } + ] + }, + "payment_methods": { + "data": [ + { + "id": "602801", + "type": "customer_payment_method" + } + ] + } + } + } + ], + "included": [ + { + "id": "108291", + "type": "customer_icp_consumer", + "attributes": { + "start_date": "2023-03-02", + "end_date": null, + "icp_number": "0001234567UNB12", + "supply_node_ref": "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef8299", + "physical_address": "123 FAKE STREET,NEWTOWN,WELLINGTON,6021" + } + } + ], + "meta": { + "verb": "get", + "type": "customer_account", + "params": [], + "permission": { + "uri": "flick:customer_app:resource:account:list", + "data_context": null + }, + "host": "https://api.flickuat.com", + "service": "customer", + "path": "/accounts", + "description": "Returns the accounts viewable by the current user", + "respond_with_array": true + } +} diff --git a/tests/components/flick_electric/fixtures/accounts_multi.json b/tests/components/flick_electric/fixtures/accounts_multi.json new file mode 100644 index 00000000000..7c1f3fba2ef --- /dev/null +++ b/tests/components/flick_electric/fixtures/accounts_multi.json @@ -0,0 +1,144 @@ +{ + "data": [ + { + "id": "134800", + "type": "customer_account", + "attributes": { + "account_number": "10123404", + "billing_name": "9973debf-963f-49b0-9a73-Ba9c3400cbed@Anonymised Example", + "billing_email": null, + "address": "123 Fake Street, Newtown, Wellington 6021", + "brand": "flick", + "vulnerability_state": "none", + "medical_dependency": false, + "status": "active", + "start_at": "2023-03-02T00:00:00.000+13:00", + "end_at": null, + "application_id": "5dfc4978-07de-4d18-8ef7-055603805ba6", + "active": true, + "on_join_journey": false, + "placeholder": false + }, + "relationships": { + "user": { + "data": { + "id": "106676", + "type": "customer_user" + } + }, + "sign_up": { + "data": { + "id": "877039", + "type": "customer_sign_up" + } + }, + "main_customer": { + "data": { + "id": "108335", + "type": "customer_customer" + } + }, + "main_consumer": { + "data": { + "id": "108291", + "type": "customer_icp_consumer" + } + }, + "primary_contact": { + "data": { + "id": "121953", + "type": "customer_contact" + } + }, + "default_payment_method": { + "data": { + "id": "602801", + "type": "customer_payment_method" + } + }, + "phone_numbers": { + "data": [ + { + "id": "111604", + "type": "customer_phone_number" + } + ] + }, + "payment_methods": { + "data": [ + { + "id": "602801", + "type": "customer_payment_method" + } + ] + } + } + }, + { + "id": "123456", + "type": "customer_account", + "attributes": { + "account_number": "123123123", + "billing_name": "9973debf-963f-49b0-9a73-Ba9c3400cbed@Anonymised Example", + "billing_email": null, + "address": "456 Fake Street, Newtown, Wellington 6021", + "brand": "flick", + "vulnerability_state": "none", + "medical_dependency": false, + "status": "active", + "start_at": "2023-03-02T00:00:00.000+13:00", + "end_at": null, + "application_id": "5dfc4978-07de-4d18-8ef7-055603805ba6", + "active": true, + "on_join_journey": false, + "placeholder": false + }, + "relationships": { + "main_consumer": { + "data": { + "id": "11223344", + "type": "customer_icp_consumer" + } + } + } + } + ], + "included": [ + { + "id": "108291", + "type": "customer_icp_consumer", + "attributes": { + "start_date": "2023-03-02", + "end_date": null, + "icp_number": "0001234567UNB12", + "supply_node_ref": "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef8299", + "physical_address": "123 FAKE STREET,NEWTOWN,WELLINGTON,6021" + } + }, + { + "id": "11223344", + "type": "customer_icp_consumer", + "attributes": { + "start_date": "2023-03-02", + "end_date": null, + "icp_number": "9991234567UNB12", + "supply_node_ref": "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef1234", + "physical_address": "456 FAKE STREET,NEWTOWN,WELLINGTON,6021" + } + } + ], + "meta": { + "verb": "get", + "type": "customer_account", + "params": [], + "permission": { + "uri": "flick:customer_app:resource:account:list", + "data_context": null + }, + "host": "https://api.flickuat.com", + "service": "customer", + "path": "/accounts", + "description": "Returns the accounts viewable by the current user", + "respond_with_array": true + } +} diff --git a/tests/components/flick_electric/fixtures/rated_period.json b/tests/components/flick_electric/fixtures/rated_period.json new file mode 100644 index 00000000000..8e6ce96a9b7 --- /dev/null +++ b/tests/components/flick_electric/fixtures/rated_period.json @@ -0,0 +1,112 @@ +{ + "data": { + "id": "_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_rated_period", + "attributes": { + "start_at": "2025-02-09T05:30:00.000Z", + "end_at": "2025-02-09T05:59:59.000Z", + "status": "final", + "cost": "0.20011", + "import_cost": "0.20011", + "export_cost": null, + "cost_unit": "NZD", + "quantity": "1.0", + "import_quantity": "1.0", + "export_quantity": null, + "quantity_unit": "kwh", + "renewable_quantity": null, + "generation_price_contract": null + }, + "relationships": { + "components": { + "data": [ + { + "id": "213507464_1_kwh_generation_UN_24_default_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component" + }, + { + "id": "213507464_1_kwh_network_UN_24_offpeak_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component" + } + ] + } + } + }, + "included": [ + { + "id": "213507464_1_kwh_generation_UN_24_default_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component", + "attributes": { + "charge_method": "kwh", + "charge_setter": "generation", + "value": "0.20011", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + "content_code": "UN", + "hours_of_availability": 24, + "channel_number": 1, + "meter_serial_number": "213507464", + "price_name": "default", + "applicable_periods": [], + "single_unit_price": "0.20011", + "billable": true, + "renewable_quantity": null, + "generation_price_contract": "FLICK_FLAT_2024_04_01_midpoint" + } + }, + { + "id": "213507464_1_kwh_network_UN_24_offpeak_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component", + "attributes": { + "charge_method": "kwh", + "charge_setter": "network", + "value": "0.0406", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + "content_code": "UN", + "hours_of_availability": 24, + "channel_number": 1, + "meter_serial_number": "213507464", + "price_name": "offpeak", + "applicable_periods": [], + "single_unit_price": "0.0406", + "billable": false, + "renewable_quantity": null, + "generation_price_contract": "FLICK_FLAT_2024_04_01_midpoint" + } + } + ], + "meta": { + "verb": "get", + "type": "rating_rated_period", + "params": [ + { + "name": "supply_node_ref", + "type": "String", + "description": "The supply node to rate", + "example": "/network/nz/supply_nodes/bccd6f52-448b-4edf-a0c1-459ee67d215b", + "required": true + }, + { + "name": "as_at", + "type": "DateTime", + "description": "The time to rate the supply node at; defaults to the current time", + "example": "2023-04-01T15:20:15-07:00", + "required": false + } + ], + "permission": { + "uri": "flick:rating:resource:rated_period:show", + "data_context": "supply_node" + }, + "host": "https://api.flickuat.com", + "service": "rating", + "path": "/rated_period", + "description": "Fetch a rated period for a supply node in a specific point in time", + "respond_with_array": false + } +} diff --git a/tests/components/flick_electric/test_config_flow.py b/tests/components/flick_electric/test_config_flow.py index 7ac605f1c8c..c14303278a3 100644 --- a/tests/components/flick_electric/test_config_flow.py +++ b/tests/components/flick_electric/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Flick Electric config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pyflick.authentication import AuthException from pyflick.types import APIException @@ -16,10 +16,16 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CONF, _mock_flick_price +from . import CONF, setup_integration from tests.common import MockConfigEntry +# From test fixtures +ACCOUNT_NAME_1 = "123 Fake Street, Newtown, Wellington 6021" +ACCOUNT_NAME_2 = "456 Fake Street, Newtown, Wellington 6021" +ACCOUNT_ID_2 = "123456" +SUPPLY_NODE_REF_2 = "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef1234" + async def _flow_submit(hass: HomeAssistant) -> ConfigFlowResult: return await hass.config_entries.flow.async_init( @@ -32,7 +38,7 @@ async def _flow_submit(hass: HomeAssistant) -> ConfigFlowResult: ) -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_flick_client: AsyncMock) -> None: """Test we get the form with only one, with no account picker.""" result = await hass.config_entries.flow.async_init( @@ -41,48 +47,24 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - } - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.components.flick_electric.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "123 Fake St" + assert result2["title"] == ACCOUNT_NAME_1 assert result2["data"] == CONF - assert result2["result"].unique_id == "1234" - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == CONF[CONF_ACCOUNT_ID] -async def test_form_multi_account(hass: HomeAssistant) -> None: +async def test_form_multi_account( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test the form when multiple accounts are available.""" result = await hass.config_entries.flow.async_init( @@ -91,272 +73,114 @@ async def test_form_multi_account(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.components.flick_electric.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "select_account" - assert len(mock_setup_entry.mock_calls) == 0 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"account_id": "5678"}, - ) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ACCOUNT_ID: ACCOUNT_ID_2}, + ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "456 Fake St" - assert result3["data"] == { - **CONF, - CONF_SUPPLY_NODE_REF: "456", - CONF_ACCOUNT_ID: "5678", - } - assert result3["result"].unique_id == "5678" - assert len(mock_setup_entry.mock_calls) == 1 + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == ACCOUNT_NAME_2 + assert result3["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: SUPPLY_NODE_REF_2, + CONF_ACCOUNT_ID: ACCOUNT_ID_2, + } + assert result3["result"].unique_id == ACCOUNT_ID_2 -async def test_reauth_token(hass: HomeAssistant) -> None: +async def test_reauth_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test reauth flow when username/password is wrong.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={**CONF}, - title="123 Fake St", - unique_id="1234", - version=2, + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ): + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: CONF[CONF_USERNAME], CONF_PASSWORD: CONF[CONF_PASSWORD]}, ) - entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - side_effect=AuthException, - ), - ): - result = await entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - assert result["step_id"] == "user" - - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.config_entries.ConfigEntries.async_update_entry", - return_value=True, - ) as mock_update_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert len(mock_update_entry.mock_calls) > 0 + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" -async def test_form_reauth_migrate(hass: HomeAssistant) -> None: +async def test_form_reauth_migrate( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test reauth flow for v1 with single account.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - title="123 Fake St", - unique_id="test-username", - version=1, - ) - entry.add_to_hass(hass) + mock_old_config_entry.add_to_hass(hass) + result = await mock_old_config_entry.start_reauth_flow(hass) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - result = await entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert entry.version == 2 - assert entry.unique_id == "1234" - assert entry.data == CONF + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_old_config_entry.version == 2 + assert mock_old_config_entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert mock_old_config_entry.data == CONF -async def test_form_reauth_migrate_multi_account(hass: HomeAssistant) -> None: +async def test_form_reauth_migrate_multi_account( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client_multiple: AsyncMock, +) -> None: """Test the form when multiple accounts are available.""" + mock_old_config_entry.add_to_hass(hass) + result = await mock_old_config_entry.start_reauth_flow(hass) - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - title="123 Fake St", - unique_id="test-username", - version=1, + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_account" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) - entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - result = await entry.start_reauth_flow(hass) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_account" + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"account_id": "5678"}, - ) - - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - assert entry.version == 2 - assert entry.unique_id == "5678" - assert entry.data == { - **CONF, - CONF_ACCOUNT_ID: "5678", - CONF_SUPPLY_NODE_REF: "456", - } + assert mock_old_config_entry.version == 2 + assert mock_old_config_entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert mock_old_config_entry.data == CONF -async def test_form_duplicate_account(hass: HomeAssistant) -> None: +async def test_form_duplicate_account( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test uniqueness for account_id.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={**CONF, CONF_ACCOUNT_ID: "1234", CONF_SUPPLY_NODE_REF: "123"}, - title="123 Fake St", - unique_id="1234", - version=2, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - } - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - result = await _flow_submit(hass) + result = await _flow_submit(hass) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -398,7 +222,9 @@ async def test_form_generic_exception(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "unknown"} -async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_select_account_cannot_connect( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test we handle connection errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -406,38 +232,16 @@ async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - side_effect=APIException, - ), + with patch.object( + mock_flick_client_multiple, + "getPricing", + side_effect=APIException, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], }, ) await hass.async_block_till_done() @@ -447,7 +251,7 @@ async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"account_id": "5678"}, + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) assert result3["type"] is FlowResultType.FORM @@ -455,7 +259,9 @@ async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: assert result3["errors"] == {"base": "cannot_connect"} -async def test_form_select_account_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_select_account_invalid_auth( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test we handle auth errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -463,65 +269,41 @@ async def test_form_select_account_invalid_auth(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - side_effect=AuthException, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "select_account" + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" with ( patch( "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", side_effect=AuthException, ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + patch.object( + mock_flick_client_multiple, + "getPricing", side_effect=AuthException, ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"account_id": "5678"}, + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "no_permissions" -async def test_form_select_account_failed_to_connect(hass: HomeAssistant) -> None: +async def test_form_select_account_failed_to_connect( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test we handle connection errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -529,115 +311,56 @@ async def test_form_select_account_failed_to_connect(hass: HomeAssistant) -> Non assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - side_effect=AuthException, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "select_account" + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + patch.object( + mock_flick_client_multiple, + "getCustomerAccounts", side_effect=APIException, ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + patch.object( + mock_flick_client_multiple, + "getPricing", side_effect=APIException, ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"account_id": "5678"}, + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.components.flick_electric.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {"account_id": "5678"}, - ) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_ACCOUNT_ID: ACCOUNT_ID_2}, + ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "456 Fake St" - assert result4["data"] == { - **CONF, - CONF_SUPPLY_NODE_REF: "456", - CONF_ACCOUNT_ID: "5678", - } - assert result4["result"].unique_id == "5678" - assert len(mock_setup_entry.mock_calls) == 1 + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == ACCOUNT_NAME_2 + assert result4["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: SUPPLY_NODE_REF_2, + CONF_ACCOUNT_ID: ACCOUNT_ID_2, + } + assert result4["result"].unique_id == ACCOUNT_ID_2 -async def test_form_select_account_no_accounts(hass: HomeAssistant) -> None: +async def test_form_select_account_no_accounts( + hass: HomeAssistant, mock_flick_client: AsyncMock +) -> None: """Test we handle connection errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -645,28 +368,23 @@ async def test_form_select_account_no_accounts(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "closed", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - ], - ), + with patch.object( + mock_flick_client, + "getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "closed", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], }, ) await hass.async_block_till_done() diff --git a/tests/components/flick_electric/test_init.py b/tests/components/flick_electric/test_init.py index e022b6e03bc..d420a78ccfc 100644 --- a/tests/components/flick_electric/test_init.py +++ b/tests/components/flick_electric/test_init.py @@ -1,135 +1,154 @@ """Test the Flick Electric config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from pyflick.authentication import AuthException +import jwt +from pyflick.types import APIException, AuthException +import pytest -from homeassistant.components.flick_electric.const import CONF_ACCOUNT_ID, DOMAIN +from homeassistant.components.flick_electric import CONF_ID_TOKEN, HassFlickAuth +from homeassistant.components.flick_electric.const import ( + CONF_ACCOUNT_ID, + CONF_TOKEN_EXPIRY, +) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import CONF, _mock_flick_price +from . import CONF, setup_integration from tests.common import MockConfigEntry - -async def test_init_auth_failure_triggers_auth(hass: HomeAssistant) -> None: - """Test reauth flow is triggered when username/password is wrong.""" - with ( - patch( - "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", - side_effect=AuthException, - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - data={**CONF}, - title="123 Fake St", - unique_id="1234", - version=2, - ) - entry.add_to_hass(hass) - - # Ensure setup fails - assert not await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR - - # Ensure reauth flow is triggered - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress()) == 1 +NEW_TOKEN = jwt.encode( + {"exp": dt_util.now().timestamp() + 86400}, "secret", algorithm="HS256" +) +EXISTING_TOKEN = jwt.encode( + {"exp": dt_util.now().timestamp() + 3600}, "secret", algorithm="HS256" +) +EXPIRED_TOKEN = jwt.encode( + {"exp": dt_util.now().timestamp() - 3600}, "secret", algorithm="HS256" +) -async def test_init_migration_single_account(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (AuthException, ConfigEntryState.SETUP_ERROR), + (APIException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_auth_failure_triggers_auth( + hass: HomeAssistant, + mock_flick_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test integration handles initialisation errors.""" + with patch.object(mock_flick_client, "getPricing", side_effect=exception): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state == config_entry_state + + +async def test_init_migration_single_account( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test migration with single account.""" - with ( - patch( - "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - } - ], - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: CONF[CONF_USERNAME], - CONF_PASSWORD: CONF[CONF_PASSWORD], - }, - title=CONF_USERNAME, - unique_id=CONF_USERNAME, - version=1, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_old_config_entry) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress()) == 0 - assert entry.state is ConfigEntryState.LOADED - assert entry.version == 2 - assert entry.unique_id == CONF[CONF_ACCOUNT_ID] - assert entry.data == CONF + assert len(hass.config_entries.flow.async_progress()) == 0 + assert mock_old_config_entry.state is ConfigEntryState.LOADED + assert mock_old_config_entry.version == 2 + assert mock_old_config_entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert mock_old_config_entry.data == CONF -async def test_init_migration_multi_account_reauth(hass: HomeAssistant) -> None: +async def test_init_migration_multi_account_reauth( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client_multiple: AsyncMock, +) -> None: """Test migration triggers reauth with multiple accounts.""" - with ( - patch( - "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: CONF[CONF_USERNAME], - CONF_PASSWORD: CONF[CONF_PASSWORD], - }, - title=CONF_USERNAME, - unique_id=CONF_USERNAME, - version=1, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_old_config_entry) - # ensure setup fails - assert not await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.MIGRATION_ERROR - await hass.async_block_till_done() + assert mock_old_config_entry.state is ConfigEntryState.MIGRATION_ERROR - # Ensure reauth flow is triggered - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress()) == 1 + # Ensure reauth flow is triggered + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_fetch_fresh_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: + """Test fetching a fresh token.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.get_new_token", + return_value={CONF_ID_TOKEN: NEW_TOKEN}, + ) as mock_get_new_token: + auth = HassFlickAuth(hass, mock_config_entry) + + assert await auth.async_get_access_token() == NEW_TOKEN + assert mock_get_new_token.call_count == 1 + + +async def test_reuse_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: + """Test reusing entry token.""" + await setup_integration(hass, mock_config_entry) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={ + **mock_config_entry.data, + CONF_ACCESS_TOKEN: {CONF_ID_TOKEN: EXISTING_TOKEN}, + CONF_TOKEN_EXPIRY: dt_util.now().timestamp() + 3600, + }, + ) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.get_new_token", + return_value={CONF_ID_TOKEN: NEW_TOKEN}, + ) as mock_get_new_token: + auth = HassFlickAuth(hass, mock_config_entry) + + assert await auth.async_get_access_token() == EXISTING_TOKEN + assert mock_get_new_token.call_count == 0 + + +async def test_fetch_expired_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: + """Test fetching token when existing token is expired.""" + await setup_integration(hass, mock_config_entry) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={ + **mock_config_entry.data, + CONF_ACCESS_TOKEN: {CONF_ID_TOKEN: EXPIRED_TOKEN}, + CONF_TOKEN_EXPIRY: dt_util.now().timestamp() - 3600, + }, + ) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.get_new_token", + return_value={CONF_ID_TOKEN: NEW_TOKEN}, + ) as mock_get_new_token: + auth = HassFlickAuth(hass, mock_config_entry) + + assert await auth.async_get_access_token() == NEW_TOKEN + assert mock_get_new_token.call_count == 1 diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr new file mode 100644 index 00000000000..edba0ebe162 --- /dev/null +++ b/tests/components/flo/snapshots/test_init.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_setup_entry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '11:11:11:11:11:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'flo', + '98765', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Flo by Moen', + 'model': 'flo_device_075_v2', + 'model_id': None, + 'name': 'Smart water shutoff', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '6.1.1', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '1a:2b:3c:4d:5e:6f', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'flo', + '32839', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Flo by Moen', + 'model': 'puck_v1', + 'model_id': None, + 'name': 'Kitchen sink', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111112', + 'suggested_area': None, + 'sw_version': '1.1.15', + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/flo/test_binary_sensor.py b/tests/components/flo/test_binary_sensor.py index 23a84734b0d..9c174abb0d6 100644 --- a/tests/components/flo/test_binary_sensor.py +++ b/tests/components/flo/test_binary_sensor.py @@ -2,18 +2,8 @@ import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_PASSWORD, - CONF_USERNAME, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry @@ -24,13 +14,9 @@ async def test_binary_sensors( ) -> None: """Test Flo by Moen sensors.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - valve_state = hass.states.get( "binary_sensor.smart_water_shutoff_pending_system_alerts" ) diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index c3e26e77370..b89d5a1e68c 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -7,13 +7,8 @@ from aioflo.errors import RequestError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.components.flo.coordinator import FloDeviceDataUpdateCoordinator -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -28,59 +23,8 @@ async def test_device( ) -> None: """Test Flo by Moen devices.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - - valve: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"][0] - assert valve.api_client is not None - assert valve.available - assert valve.consumption_today == 3.674 - assert valve.current_flow_rate == 0 - assert valve.current_psi == 54.20000076293945 - assert valve.current_system_mode == "home" - assert valve.target_system_mode == "home" - assert valve.firmware_version == "6.1.1" - assert valve.device_type == "flo_device_v2" - assert valve.id == "98765" - assert valve.last_heard_from_time == "2020-07-24T12:45:00Z" - assert valve.location_id == "mmnnoopp" - assert valve.hass is not None - assert valve.temperature == 70 - assert valve.mac_address == "111111111111" - assert valve.model == "flo_device_075_v2" - assert valve.manufacturer == "Flo by Moen" - assert valve.device_name == "Smart Water Shutoff" - assert valve.rssi == -47 - assert valve.pending_info_alerts_count == 0 - assert valve.pending_critical_alerts_count == 0 - assert valve.pending_warning_alerts_count == 2 - assert valve.has_alerts is True - assert valve.last_known_valve_state == "open" - assert valve.target_valve_state == "open" - - detector: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"][1] - assert detector.api_client is not None - assert detector.available - assert detector.device_type == "puck_oem" - assert detector.id == "32839" - assert detector.last_heard_from_time == "2021-03-07T14:05:00Z" - assert detector.location_id == "mmnnoopp" - assert detector.hass is not None - assert detector.temperature == 61 - assert detector.humidity == 43 - assert detector.water_detected is False - assert detector.mac_address == "1a2b3c4d5e6f" - assert detector.model == "puck_v1" - assert detector.manufacturer == "Flo by Moen" - assert detector.device_name == "Kitchen Sink" - assert detector.serial_number == "111111111112" call_count = aioclient_mock.call_count diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py index 805a6278395..c1983b898da 100644 --- a/tests/components/flo/test_init.py +++ b/tests/components/flo/test_init.py @@ -1,25 +1,32 @@ """Test init.""" import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @pytest.mark.usefixtures("aioclient_mock_fixture") -async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_setup_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test migration of config entry from v1.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + == snapshot + ) assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py index 0c763927296..828e4f3b4d5 100644 --- a/tests/components/flo/test_sensor.py +++ b/tests/components/flo/test_sensor.py @@ -2,15 +2,12 @@ import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .common import TEST_PASSWORD, TEST_USER_ID - from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -20,13 +17,9 @@ async def test_sensors(hass: HomeAssistant, config_entry: MockConfigEntry) -> No """Test Flo by Moen sensors.""" hass.config.units = US_CUSTOMARY_SYSTEM config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - # we should have 5 entities for the valve assert ( hass.states.get("sensor.smart_water_shutoff_current_system_mode").state @@ -95,13 +88,9 @@ async def test_manual_update_entity( ) -> None: """Test manual update entity via service homeasasistant/update_entity.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - await async_setup_component(hass, "homeassistant", {}) call_count = aioclient_mock.call_count diff --git a/tests/components/flo/test_services.py b/tests/components/flo/test_services.py index 565f39f69fe..980d5906a56 100644 --- a/tests/components/flo/test_services.py +++ b/tests/components/flo/test_services.py @@ -13,11 +13,8 @@ from homeassistant.components.flo.switch import ( SERVICE_SET_SLEEP_MODE, SYSTEM_MODE_HOME, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -33,12 +30,9 @@ async def test_services( ) -> None: """Test Flo services.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 assert aioclient_mock.call_count == 8 await hass.services.async_call( diff --git a/tests/components/flo/test_switch.py b/tests/components/flo/test_switch.py index 5c124d312a7..86fa8dd522d 100644 --- a/tests/components/flo/test_switch.py +++ b/tests/components/flo/test_switch.py @@ -2,13 +2,9 @@ import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry @@ -19,13 +15,9 @@ async def test_valve_switches( ) -> None: """Test Flo by Moen valve switches.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - entity_id = "switch.smart_water_shutoff_shutoff_valve" assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index f7dc30db240..e1bd07cdfd7 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/flux_led/__init__.py b/tests/components/flux_led/__init__.py index d1cb892d548..c8bd0bb192c 100644 --- a/tests/components/flux_led/__init__.py +++ b/tests/components/flux_led/__init__.py @@ -24,12 +24,12 @@ from flux_led.protocol import ( ) from flux_led.scanner import FluxLEDDiscovery -from homeassistant.components import dhcp from homeassistant.components.flux_led.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -49,7 +49,7 @@ SHORT_MAC_ADDRESS = "DDEEFF" DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=MODEL, ip=IP_ADDRESS, macaddress=format_mac(MAC_ADDRESS).replace(":", ""), diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index 4332cb69f02..f486d27244e 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.flux_led.config_flow import FluxLedConfigFlow from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, @@ -27,6 +26,7 @@ from homeassistant.components.flux_led.const import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_TITLE, @@ -424,7 +424,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index 2ed0d34989f..8dd8196a2db 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, diff --git a/tests/components/folder_watcher/snapshots/test_event.ambr b/tests/components/folder_watcher/snapshots/test_event.ambr index 04405e0694b..1101380703a 100644 --- a/tests/components/folder_watcher/snapshots/test_event.ambr +++ b/tests/components/folder_watcher/snapshots/test_event.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index 6ae4c2f6198..c0db54c2d4e 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -23,6 +23,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Green House', 'unique_id': 'unique', 'version': 2, diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 076fffef59b..8bf5de31da2 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components import zeroconf from homeassistant.components.forked_daapd.const import ( CONF_LIBRESPOT_JAVA_PORT, CONF_MAX_PLAYLISTS, @@ -19,6 +18,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -109,7 +109,7 @@ async def test_zeroconf_updates_title( MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "different host"}).add_to_hass(hass) config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", @@ -146,7 +146,7 @@ async def test_config_flow_no_websocket( async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -161,7 +161,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -176,7 +176,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -191,7 +191,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -209,7 +209,7 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" - discovery_info = zeroconf.ZeroconfServiceInfo( + discovery_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.1"), ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py index 0b82ed3b02a..a7b6a8c8f0b 100644 --- a/tests/components/foscam/test_init.py +++ b/tests/components/foscam/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components.foscam import DOMAIN, config_flow +from homeassistant.components.foscam.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -18,9 +18,7 @@ async def test_unique_id_new_entry( entity_registry: er.EntityRegistry, ) -> None: """Test unique ID for a newly added device is correct.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID - ) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) entry.add_to_hass(hass) with ( @@ -46,7 +44,7 @@ async def test_switch_unique_id_migration_ok( ) -> None: """Test that the unique ID for a sleep switch is migrated to the new format.""" entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 + domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 ) entry.add_to_hass(hass) @@ -57,7 +55,7 @@ async def test_switch_unique_id_migration_ok( # Update config entry with version 2 entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 + domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 ) entry.add_to_hass(hass) @@ -84,9 +82,7 @@ async def test_unique_id_migration_not_needed( entity_registry: er.EntityRegistry, ) -> None: """Test that the unique ID for a sleep switch is not executed if already in right format.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID - ) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) entry.add_to_hass(hass) entity_registry.async_get_or_create( diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index ca9e9c12937..50dd2f8c14e 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -9,18 +9,18 @@ from freebox_api.exceptions import ( InvalidTokenError, ) -from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_HOST, MOCK_PORT from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.254"), ip_addresses=[ip_address("192.168.0.254")], port=80, diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index acd96879b1e..1e292ed22bb 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -1,8 +1,6 @@ """Common stuff for Fritz!Tools tests.""" -from homeassistant.components import ssdp from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.const import ( CONF_DEVICES, CONF_HOST, @@ -11,6 +9,11 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) ATTR_HOST = "host" ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" @@ -941,7 +944,7 @@ MOCK_DEVICE_INFO = { ATTR_HOST: MOCK_HOST, ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, } -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://{MOCK_IPS['fritz.box']}:12345/test", diff --git a/tests/components/fritz/snapshots/test_button.ambr b/tests/components/fritz/snapshots/test_button.ambr index ed0b0e72160..748d8c1ba29 100644 --- a/tests/components/fritz/snapshots/test_button.ambr +++ b/tests/components/fritz/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 53f7093a21b..9b5b8c9353a 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -61,6 +61,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 50744815aa5..5ff0e448b15 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -249,6 +254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -298,6 +304,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -345,6 +352,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,6 +400,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -439,6 +448,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -487,6 +497,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -581,6 +593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -629,6 +642,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -677,6 +691,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -727,6 +742,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index b34a3626fe2..a1097d3333b 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -482,6 +492,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +540,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/snapshots/test_update.ambr b/tests/components/fritz/snapshots/test_update.ambr index 3c7880d01e7..746823e9dc9 100644 --- a/tests/components/fritz/snapshots/test_update.ambr +++ b/tests/components/fritz/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 84f1b240b88..f4c4229af74 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,7 +10,6 @@ from fritzconnection.core.exceptions import ( ) import pytest -from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -33,6 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import ( MOCK_FIRMWARE_INFO, @@ -644,7 +648,7 @@ async def test_ssdp_already_in_progress_host( MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() - del MOCK_NO_UNIQUE_ID.upnp[ssdp.ATTR_UPNP_UDN] + del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) @@ -745,13 +749,13 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1ff:fe23:4567:890a]:12345/test", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "fake_name", - ssdp.ATTR_UPNP_UDN: "uuid:only-a-test", + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", }, ), ) diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 7dec640b898..1b10ddb8fc1 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_USER_DATA diff --git a/tests/components/fritz/test_services.py b/tests/components/fritz/test_services.py new file mode 100644 index 00000000000..d7b85cbc448 --- /dev/null +++ b/tests/components/fritz/test_services.py @@ -0,0 +1,134 @@ +"""Tests for Fritz!Tools services.""" + +from unittest.mock import patch + +from fritzconnection.core.exceptions import FritzConnectionException, FritzServiceError +import pytest + +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.components.fritz.services import SERVICE_SET_GUEST_WIFI_PW +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_setup_services(hass: HomeAssistant) -> None: + """Test setup of Fritz!Tools services.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_SET_GUEST_WIFI_PW in services + + +async def test_service_set_guest_wifi_password( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + + +async def test_service_set_guest_wifi_password_unknown_parameter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with unknown parameter.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzServiceError("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action or parameter unknown" in caplog.text + + +async def test_service_set_guest_wifi_password_service_not_supported( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with connection error.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzConnectionException("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action not supported" in caplog.text + + +async def test_service_set_guest_wifi_password_unloaded( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": "12345678"} + ) + assert not mock_async_trigger_set_guest_password.called + assert ( + 'ServiceValidationError: Failed to perform action "set_guest_wifi_password". Config entry for target not found' + in caplog.text + ) diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index f4cc1b2e2ca..594ed14a7d1 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 913f828efbc..0053a8d3446 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 29f5742216f..f170836fa9b 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -23,7 +23,12 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.fritzbox.climate import PRESET_HOLIDAY, PRESET_SUMMER +from homeassistant.components.fritzbox.climate import ( + OFF_API_TEMPERATURE, + ON_API_TEMPERATURE, + PRESET_HOLIDAY, + PRESET_SUMMER, +) from homeassistant.components.fritzbox.const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -44,7 +49,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, @@ -273,20 +278,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( ("service_data", "expected_call_args"), [ - ({ATTR_TEMPERATURE: 23}, [call(23)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)]), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0)], + [call(0, True)], ), ( { ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 23, }, - [call(23)], + [call(23, True)], ), ], ) @@ -316,14 +321,14 @@ async def test_set_temperature( ("service_data", "target_temperature", "current_preset", "expected_call_args"), [ # mode off always sets target temperature to 0 - ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), # mode heat sets target temperature based on current scheduled preset, # when not already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), # mode heat does not set target temperature, when already in mode heat ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), @@ -367,9 +372,23 @@ async def test_set_hvac_mode( assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("comfort_temperature", "expected_call_args"), + [ + (20, [call(20, True)]), + (28, [call(28, True)]), + (ON_API_TEMPERATURE, [call(30, True)]), + ], +) +async def test_set_preset_mode_comfort( + hass: HomeAssistant, + fritz: Mock, + comfort_temperature: int, + expected_call_args: list[_Call], +) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + device.comfort_temperature = comfort_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -380,12 +399,27 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_args_list == [call(22)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("eco_temperature", "expected_call_args"), + [ + (20, [call(20, True)]), + (16, [call(16, True)]), + (OFF_API_TEMPERATURE, [call(0, True)]), + ], +) +async def test_set_preset_mode_eco( + hass: HomeAssistant, + fritz: Mock, + eco_temperature: int, + expected_call_args: list[_Call], +) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + device.eco_temperature = eco_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -396,7 +430,8 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_args_list == [call(16)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index 0df6d0b2ea9..0c8a7996898 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -9,13 +9,16 @@ from pyfritzhome import LoginError import pytest from requests.exceptions import HTTPError -from homeassistant.components import ssdp from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .const import CONF_FAKE_NAME, MOCK_CONFIG @@ -23,7 +26,7 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { - "ip4_valid": ssdp.SsdpServiceInfo( + "ip4_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://10.0.0.1:12345/test", @@ -32,7 +35,7 @@ MOCK_SSDP_DATA = { ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_valid": ssdp.SsdpServiceInfo( + "ip6_valid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[1234::1]:12345/test", @@ -41,7 +44,7 @@ MOCK_SSDP_DATA = { ATTR_UPNP_UDN: "uuid:only-a-test", }, ), - "ip6_invalid": ssdp.SsdpServiceInfo( + "ip6_invalid": SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://[fe80::1%1]:12345/test", @@ -264,7 +267,7 @@ async def test_reconfigure_failed(hass: HomeAssistant, fritz: Mock) -> None: async def test_ssdp( hass: HomeAssistant, fritz: Mock, - test_data: ssdp.SsdpServiceInfo, + test_data: SsdpServiceInfo, expected_result: str, ) -> None: """Test starting a flow from discovery.""" diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index f26e65fc28a..535306e4ef2 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceCoverMock, @@ -99,7 +99,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, True, ) - assert device.set_level_percentage.call_args_list == [call(50)] + assert device.set_level_percentage.call_args_list == [call(50, True)] async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 84fafe25521..fe8bb32066e 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import Mock, call -import pytest from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import ( @@ -31,7 +30,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG @@ -155,8 +154,8 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color_temp.call_count == 1 - assert device.set_color_temp.call_args_list == [call(3000)] - assert device.set_level.call_args_list == [call(100)] + assert device.set_color_temp.call_args_list == [call(3000, 0, True)] + assert device.set_level.call_args_list == [call(100, True)] async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: @@ -166,6 +165,7 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = True assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -178,13 +178,14 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] + assert device.set_color.call_count == 0 + assert device.set_level.call_args_list == [call(100, True)] assert device.set_unmapped_color.call_args_list == [ - call((100, round(70 * 255.0 / 100.0))) + call((100, round(70 * 255.0 / 100.0)), 0, True) ] -async def test_turn_on_color_unsupported_api_method( +async def test_turn_on_color_no_fullcolorsupport( hass: HomeAssistant, fritz: Mock ) -> None: """Test turn device on in mapped color mode if unmapped is not supported.""" @@ -193,16 +194,11 @@ async def test_turn_on_color_unsupported_api_method( device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = False assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - # test fallback to `setcolor` - error = HTTPError("Bad Request") - error.response = Mock() - error.response.status_code = 400 - device.set_unmapped_color.side_effect = error - await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -212,18 +208,9 @@ async def test_turn_on_color_unsupported_api_method( assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] - assert device.set_color.call_args_list == [call((100, 70))] - - # test for unknown error - error.response.status_code = 500 - with pytest.raises(HTTPError, match="Bad Request"): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, - True, - ) + assert device.set_unmapped_color.call_count == 0 + assert device.set_level.call_args_list == [call(100, True)] + assert device.set_color.call_args_list == [call((100, 70), 0, True)] async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 0da040bbb5b..67b2c3e8ab6 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index e394ccbc7f3..511725c663f 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index 010de06e276..b112839835a 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr index 81770893273..5384e9c6389 100644 --- a/tests/components/fronius/snapshots/test_sensor.ambr +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +370,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,6 +516,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -655,6 +664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +714,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -750,6 +761,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -807,6 +819,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -866,6 +879,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -917,6 +931,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -968,6 +983,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1019,6 +1035,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1070,6 +1087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1121,6 +1139,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1172,6 +1191,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1223,6 +1243,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1274,6 +1295,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1323,6 +1345,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1377,6 +1400,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1433,6 +1457,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1483,6 +1508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1533,6 +1559,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1583,6 +1610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1633,6 +1661,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1683,6 +1712,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1733,6 +1763,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1784,6 +1815,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1835,6 +1867,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1886,6 +1919,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1937,6 +1971,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1988,6 +2023,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2039,6 +2075,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2090,6 +2127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2141,6 +2179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2192,6 +2231,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2243,6 +2283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2294,6 +2335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2345,6 +2387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2396,6 +2439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2447,6 +2491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2498,6 +2543,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2549,6 +2595,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2600,6 +2647,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2649,6 +2697,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2697,6 +2746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2748,6 +2798,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2799,6 +2850,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2850,6 +2902,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2901,6 +2954,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2952,6 +3006,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3003,6 +3058,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3054,6 +3110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3104,6 +3161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3154,6 +3212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3205,6 +3264,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3256,6 +3316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3305,6 +3366,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3352,6 +3414,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3401,6 +3464,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3452,6 +3516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3503,6 +3568,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3554,6 +3620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3605,6 +3672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3656,6 +3724,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3707,6 +3776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3758,6 +3828,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3809,6 +3880,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3858,6 +3930,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4003,6 +4076,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4150,6 +4224,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4199,6 +4274,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4245,6 +4321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4302,6 +4379,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4361,6 +4439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4412,6 +4491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4463,6 +4543,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4512,6 +4593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4567,6 +4649,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4624,6 +4707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4675,6 +4759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4726,6 +4811,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4777,6 +4863,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4828,6 +4915,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4879,6 +4967,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4930,6 +5019,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4981,6 +5071,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5032,6 +5123,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5081,6 +5173,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5135,6 +5228,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5191,6 +5285,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5241,6 +5336,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5291,6 +5387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5341,6 +5438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5391,6 +5489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5441,6 +5540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5491,6 +5591,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5542,6 +5643,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5593,6 +5695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5644,6 +5747,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5695,6 +5799,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5746,6 +5851,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5797,6 +5903,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5848,6 +5955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5899,6 +6007,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5950,6 +6059,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6001,6 +6111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6052,6 +6163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6103,6 +6215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6154,6 +6267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6205,6 +6319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6256,6 +6371,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6307,6 +6423,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6358,6 +6475,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6407,6 +6525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6455,6 +6574,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6506,6 +6626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6557,6 +6678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6608,6 +6730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6659,6 +6782,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6710,6 +6834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6761,6 +6886,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6812,6 +6938,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6863,6 +6990,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6914,6 +7042,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6965,6 +7094,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7015,6 +7145,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7065,6 +7196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7116,6 +7248,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7167,6 +7300,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7218,6 +7352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7269,6 +7404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7320,6 +7456,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7371,6 +7508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7422,6 +7560,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7471,6 +7610,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7616,6 +7756,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7763,6 +7904,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7812,6 +7954,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7858,6 +8001,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7904,6 +8048,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7961,6 +8106,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8020,6 +8166,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8071,6 +8218,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8122,6 +8270,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8173,6 +8322,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8224,6 +8374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8275,6 +8426,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8326,6 +8478,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8377,6 +8530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8426,6 +8580,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8571,6 +8726,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8718,6 +8874,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8767,6 +8924,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8813,6 +8971,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8859,6 +9018,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8916,6 +9076,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8975,6 +9136,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9024,6 +9186,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9078,6 +9241,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9134,6 +9298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9185,6 +9350,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9235,6 +9401,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9286,6 +9453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9337,6 +9505,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9387,6 +9556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9435,6 +9605,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9483,6 +9654,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9534,6 +9706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9585,6 +9758,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9636,6 +9810,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9687,6 +9862,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9738,6 +9914,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9789,6 +9966,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9840,6 +10018,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9890,6 +10069,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9940,6 +10120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 933b8fad8ef..819f960c64b 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -6,11 +6,11 @@ from pyfronius import FroniusError import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fronius.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import mock_responses diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index c92cf897fe6..f60e9ad557e 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -6,7 +6,6 @@ from afsapi import ConnectionError, InvalidPinException, NotImplementedException import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.frontier_silicon.const import ( CONF_WEBFSAPI_URL, DEFAULT_PIN, @@ -15,13 +14,14 @@ from homeassistant.components.frontier_silicon.const import ( from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", @@ -30,7 +30,7 @@ MOCK_DISCOVERY = ssdp.SsdpServiceInfo( upnp={"SPEAKER-NAME": "Speaker Name"}, ) -INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( +INVALID_MOCK_DISCOVERY = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", ssdp_st="mock_st", diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index 5974adbeb0d..71a11557b44 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Fujitsu HVAC (based on Ayla IOT) tests.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import AsyncMock, create_autospec, patch from ayla_iot_unofficial import AylaApi @@ -12,7 +12,8 @@ from homeassistant.components.fujitsu_fglair.const import ( DOMAIN, REGION_DEFAULT, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,6 +34,12 @@ TEST_PROPERTY_VALUES = { } +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -78,6 +85,24 @@ def mock_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: ) +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + platforms: list[Platform], + mock_config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + mock_config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch("homeassistant.components.fujitsu_fglair.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + def _create_device(serial_number: str) -> AsyncMock: dev = AsyncMock(spec=FujitsuHVAC) dev.device_serial_number = serial_number @@ -109,6 +134,7 @@ def _create_device(serial_number: str) -> AsyncMock: dev.temperature_range = [18.0, 26.0] dev.sensed_temp = 22.0 dev.set_temp = 21.0 + dev.outdoor_temperature = 5.0 return dev diff --git a/tests/components/fujitsu_fglair/snapshots/test_climate.ambr b/tests/components/fujitsu_fglair/snapshots/test_climate.ambr index 31b143c6f95..21c5b3429f4 100644 --- a/tests/components/fujitsu_fglair/snapshots/test_climate.ambr +++ b/tests/components/fujitsu_fglair/snapshots/test_climate.ambr @@ -28,6 +28,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +123,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..751ad3cd2d9 --- /dev/null +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -0,0 +1,105 @@ +# serializer version: 1 +# name: test_entities[sensor.testserial123_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial123_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial123_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial123_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial123 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial123_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial345_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial345_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial345 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial345_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index daddc83a871..676ff97f26a 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -1,7 +1,9 @@ """Test for the climate entities of Fujitsu HVAC.""" +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.climate import ( @@ -23,24 +25,32 @@ from homeassistant.components.fujitsu_fglair.climate import ( HA_TO_FUJI_HVAC, HA_TO_FUJI_SWING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import entity_id, setup_integration +from . import entity_id from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_ayla_api: AsyncMock, mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that coordinator returns the data we expect after the first refresh.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -51,9 +61,10 @@ async def test_set_attributes( mock_ayla_api: AsyncMock, mock_devices: list[AsyncMock], mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that setting the attributes calls the correct functions on the device.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index af51b222c19..a69610c416d 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -7,6 +7,7 @@ from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.climate import HVACMode from homeassistant.components.fujitsu_fglair.const import ( API_REFRESH, API_TIMEOUT, @@ -17,14 +18,9 @@ from homeassistant.components.fujitsu_fglair.const import ( REGION_EU, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_UNAVAILABLE, - Platform, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers import aiohttp_client from . import entity_id, setup_integration from .conftest import TEST_PASSWORD, TEST_USERNAME @@ -129,6 +125,26 @@ async def test_device_auth_failure( assert hass.states.get(entity_id(mock_devices[1])).state == STATE_UNAVAILABLE +async def test_device_offline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: list[AsyncMock], +) -> None: + """Test entities become unavailable if device if offline.""" + await setup_integration(hass, mock_config_entry) + + mock_ayla_api.async_get_devices.return_value[0].is_online.return_value = False + + freezer.tick(API_REFRESH) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id(mock_devices[0])).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id(mock_devices[1])).state == HVACMode.COOL + + async def test_token_expired( hass: HomeAssistant, mock_ayla_api: AsyncMock, @@ -166,36 +182,3 @@ async def test_startup_exception( await setup_integration(hass, mock_config_entry) assert len(hass.states.async_all()) == 0 - - -async def test_one_device_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, - mock_devices: list[AsyncMock], - mock_ayla_api: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that coordinator only updates devices that are currently listening.""" - await setup_integration(hass, mock_config_entry) - - for d in mock_devices: - d.async_update.assert_called_once() - d.reset_mock() - - entity = entity_registry.async_get( - entity_registry.async_get_entity_id( - Platform.CLIMATE, DOMAIN, mock_devices[0].device_serial_number - ) - ) - entity_registry.async_update_entity( - entity.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - await hass.async_block_till_done() - freezer.tick(API_REFRESH) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == len(mock_devices) - 1 - mock_devices[0].async_update.assert_not_called() - mock_devices[1].async_update.assert_called_once() diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py new file mode 100644 index 00000000000..e3f6109a2e8 --- /dev/null +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -0,0 +1,33 @@ +"""Test for the sensor platform entity of the fujitsu_fglair component.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py index 873fb2c6796..4ce393a417d 100644 --- a/tests/components/fully_kiosk/test_config_flow.py +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -6,7 +6,6 @@ from aiohttp.client_exceptions import ClientConnectorError from fullykiosk import FullyKioskError import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_MQTT, SOURCE_USER from homeassistant.const import ( @@ -18,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index 299b96be959..92abab7091a 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -81,3 +81,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.fyta.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock image access token which normally is randomized.""" + with patch( + "homeassistant.components.image.SystemRandom.getrandbits", + return_value=1, + ): + yield diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 600fc46608c..21e1fcfb0ab 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -1,6 +1,9 @@ { "battery_level": 80, - "low_battery": true, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, "light_status": 3, @@ -10,14 +13,14 @@ "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", - "sensor_update_available": false, + "sensor_update_available": true, "sw_version": "1.0", "status": 1, "online": true, "ph": null, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json new file mode 100644 index 00000000000..98a4c6a9d91 --- /dev/null +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -0,0 +1,30 @@ +{ + "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, + "last_updated": "2023-01-10 10:10:00", + "light": 2, + "light_status": 3, + "nickname": "Gummibaum", + "nutrients_status": 3, + "moisture": 61, + "moisture_status": 3, + "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E2", + "sensor_update_available": true, + "sw_version": "1.0", + "status": 1, + "online": true, + "ph": null, + "plant_id": 0, + "plant_origin_path": "http://www.plant_picture.com/picture1", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", + "is_productive_plant": false, + "salinity": 1, + "salinity_status": 4, + "scientific_name": "Ficus elastica", + "temperature": 25.2, + "temperature_status": 3 +} diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index c39e2ac8685..bf90ab1e50d 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 58e3e1b86a0..4bb4e0b81a7 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -1,5 +1,8 @@ { "battery_level": 80, + "fertilisation": { + "was_repotted": false + }, "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, @@ -16,8 +19,8 @@ "online": true, "ph": 7, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/snapshots/test_binary_sensor.ambr b/tests/components/fyta/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1218a3da71c --- /dev/null +++ b/tests/components/fyta/snapshots/test_binary_sensor.ambr @@ -0,0 +1,757 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.gummibaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Gummibaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gummibaum_repotted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Gummibaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.gummibaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.gummibaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kakaobaum Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_light', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_light_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Light notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_light_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nutrition notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_nutrition', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_nutrition', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_nutrition_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Nutrition notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_nutrition_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Productive plant', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'productive_plant', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-productive_plant', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_productive_plant-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Productive plant', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_productive_plant', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kakaobaum_repotted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Repotted', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repotted', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-repotted', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_repotted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Repotted', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_repotted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_temperature', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_temperature_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Temperature notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_temperature_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-sensor_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Kakaobaum Update', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water notification', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'notification_water', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-notification_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kakaobaum_water_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Water notification', + }), + 'context': , + 'entity_id': 'binary_sensor.kakaobaum_water_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index eb19797e5b1..24206fbb875 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'fyta_user', 'unique_id': None, 'version': 1, @@ -31,7 +33,7 @@ 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, 'light_status': 3, - 'low_battery': True, + 'low_battery': False, 'moisture': 61.0, 'moisture_status': 3, 'name': 'Gummibaum', @@ -43,17 +45,17 @@ 'online': True, 'ph': None, 'plant_id': 0, - 'plant_origin_path': '', - 'plant_thumb_path': '', + 'plant_origin_path': 'http://www.plant_picture.com/picture', + 'plant_thumb_path': 'http://www.plant_picture.com/picture_thumb', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, 'sensor_id': 'FD:1D:B7:E3:D0:E2', 'sensor_status': 0, - 'sensor_update_available': False, + 'sensor_update_available': True, 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, @@ -81,7 +83,7 @@ 'plant_origin_path': '', 'plant_thumb_path': '', 'productive_plant': False, - 'repotted': False, + 'repotted': True, 'salinity': 1.0, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr new file mode 100644 index 00000000000..cb39efb4500 --- /dev/null +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[image.gummibaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.gummibaum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.gummibaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.gummibaum?token=1', + 'friendly_name': 'Gummibaum', + }), + 'context': , + 'entity_id': 'image.gummibaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[image.kakaobaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', + 'friendly_name': 'Kakaobaum', + }), + 'context': , + 'entity_id': 'image.kakaobaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index 8b75579f557..c43a7446f11 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -163,6 +166,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -220,6 +224,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -278,6 +283,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +339,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +396,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +454,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +510,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -556,6 +566,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +625,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -669,6 +681,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -717,6 +730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -775,6 +789,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -832,6 +847,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -881,6 +897,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +947,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -987,6 +1005,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1044,6 +1063,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1102,6 +1122,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1157,6 +1178,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1213,6 +1235,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1270,6 +1293,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1325,6 +1349,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1380,6 +1405,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1438,6 +1464,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1493,6 +1520,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1541,6 +1569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1599,6 +1628,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fyta/test_binary_sensor.py b/tests/components/fyta/test_binary_sensor.py new file mode 100644 index 00000000000..9d6a4ae3b0e --- /dev/null +++ b/tests/components/fyta/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test the Home Assistant fyta binary sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_UNAVAILABLE + ) + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert hass.states.get("binary_sensor.gummibaum_repotted").state == STATE_ON + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.kakaobaum_repotted") is None + assert hass.states.get("binary_sensor.tomatenpflanze_repotted").state == STATE_OFF diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 21101db8534..d1e6e326737 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -10,11 +10,11 @@ from fyta_cli.fyta_exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py new file mode 100644 index 00000000000..4feb125bd15 --- /dev/null +++ b/tests/components/fyta/test_image.py @@ -0,0 +1,129 @@ +"""Test the Home Assistant fyta sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.components.image import ImageEntity +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +async def test_all_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + assert len(hass.states.async_all("image")) == 2 + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.gummibaum").state == STATE_UNAVAILABLE + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + assert hass.states.get("image.gummibaum") is not None + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.kakaobaum") is None + assert hass.states.get("image.tomatenpflanze") is not None + + +async def test_update_image( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entity picture is updated.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + image_entity: ImageEntity = hass.data["domain_entities"]["image"]["image.gummibaum"] + + assert image_entity.image_url == "http://www.plant_picture.com/picture" + + plants: dict[int, Plant] = { + 0: Plant.from_dict( + load_json_object_fixture("plant_status1_update.json", FYTA_DOMAIN) + ), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert image_entity.image_url == "http://www.plant_picture.com/picture1" diff --git a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr index 5f6511090ee..b93a8656ecc 100644 --- a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr index 2c579631bae..3453817da10 100644 --- a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -155,6 +158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 6d521b1f2c8..10f23759fae 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -66,10 +66,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'bluetooth', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, @@ -223,10 +227,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 71195918bb1..8dc9d220e85 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index 4ea28bd8fd3..68e2d061259 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 87b66295006..01609cf485e 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index cdea83b599c..96cdfe41d0d 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Generator from io import BytesIO -from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from PIL import Image import pytest @@ -54,11 +54,15 @@ def fakeimgbytes_gif() -> bytes: @pytest.fixture def fakeimg_png(fakeimgbytes_png: bytes) -> Generator[None]: """Set up respx to respond to test url with fake image bytes.""" - respx.get("http://127.0.0.1/testurl/1", name="fake_img").respond( + respx.get("http://127.0.0.1/testurl/1", name="fake_img1").respond( + stream=fakeimgbytes_png + ) + respx.get("http://127.0.0.1/testurl/2", name="fake_img2").respond( stream=fakeimgbytes_png ) yield - respx.pop("fake_img") + respx.pop("fake_img1") + respx.pop("fake_img2") @pytest.fixture @@ -71,8 +75,8 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]: respx.pop("fake_img") -@pytest.fixture -def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: +@pytest.fixture(name="mock_create_stream") +def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock create stream.""" mock_stream = MagicMock() mock_stream.hass = hass @@ -83,14 +87,25 @@ def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: mock_stream.start = AsyncMock() mock_stream.stop = AsyncMock() mock_stream.endpoint_url.return_value = "http://127.0.0.1/nothing" - return patch( + with patch( "homeassistant.components.generic.config_flow.create_stream", return_value=mock_stream, - ) + ) as mock_create_stream: + yield mock_create_stream @pytest.fixture -async def user_flow(hass: HomeAssistant) -> ConfigFlowResult: +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setup entry.""" + with patch( + "homeassistant.components.generic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="user_flow") +async def user_flow_fixture(hass: HomeAssistant) -> ConfigFlowResult: """Initiate a user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -126,8 +141,8 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: return entry -@pytest.fixture -async def setup_entry( +@pytest.fixture(name="setup_entry") +async def setup_entry_fixture( hass: HomeAssistant, config_entry: MockConfigEntry ) -> MockConfigEntry: """Set up a config entry ready to be used in tests.""" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 9eee49619b5..19af6cd7a09 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator import contextlib import errno from http import HTTPStatus @@ -9,12 +10,10 @@ import os.path from pathlib import Path from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch -from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx -from homeassistant import config_entries from homeassistant.components.camera import async_get_image from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( @@ -30,7 +29,7 @@ from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) -from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -44,7 +43,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator TESTDATA = { @@ -69,52 +68,47 @@ TESTDATA_YAML = { @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form( hass: HomeAssistant, fakeimgbytes_png: bytes, hass_client: ClientSessionGenerator, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], hass_ws_client: WebSocketGenerator, ) -> None: """Test the form with a normal set of settings.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with ( - mock_create_stream as mock_setup, - patch( - "homeassistant.components.generic.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" - # HA should now be serving a WS connection for a preview stream. - ws_client = await hass_ws_client() - flow_id = user_flow["flow_id"] - await ws_client.send_json_auto_id( - { - "type": "generic_camera/start_preview", - "flow_id": flow_id, - }, - ) - json = await ws_client.receive_json() + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + }, + ) + json = await ws_client.receive_json() - client = await hass_client() - still_preview_url = json["event"]["attributes"]["still_url"] - # Check the preview image works. - resp = await client.get(still_preview_url) - assert resp.status == HTTPStatus.OK - assert await resp.read() == fakeimgbytes_png + client = await hass_client() + still_preview_url = json["event"]["attributes"]["still_url"] + # Check the preview image works. + resp = await client.get(still_preview_url) + assert resp.status == HTTPStatus.OK + assert await resp.read() == fakeimgbytes_png - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -131,36 +125,29 @@ async def test_form( # Check that the preview image is disabled after. resp = await client.get(still_preview_url) assert resp.status == HTTPStatus.NOT_FOUND - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_only_stillimage( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants still images only.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -177,19 +164,17 @@ async def test_form_only_stillimage( @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_reject_preview( hass: HomeAssistant, - fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, ) -> None: """Test we go back to the config screen if the user rejects the preview.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result1["type"] is FlowResultType.FORM assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( @@ -215,7 +200,6 @@ async def test_form_still_preview_cam_off( "homeassistant.components.generic.camera.GenericCamera.is_on", new_callable=PropertyMock(return_value=False), ), - mock_create_stream, ): result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], @@ -246,47 +230,50 @@ async def test_form_still_preview_cam_off( @respx.mock @pytest.mark.usefixtures("fakeimg_gif") async def test_form_only_stillimage_gif( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @respx.mock async def test_form_only_svg_whitespace( - hass: HomeAssistant, fakeimgbytes_svg: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, + fakeimgbytes_svg: bytes, + user_flow: ConfigFlowResult, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if svg starts with whitespace, issue #68889.""" fakeimgbytes_wspace_svg = bytes(" \n ", encoding="utf-8") + fakeimgbytes_svg respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -303,7 +290,7 @@ async def test_form_only_svg_whitespace( ], ) async def test_form_only_still_sample( - hass: HomeAssistant, user_flow: ConfigFlowResult, image_file + hass: HomeAssistant, user_flow: ConfigFlowResult, image_file, mock_setup_entry ) -> None: """Test various sample images #69037.""" image_path = os.path.join(os.path.dirname(__file__), image_file) @@ -311,18 +298,17 @@ async def test_form_only_still_sample( respx.get("http://127.0.0.1/testurl/1").respond(stream=image_bytes) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY @@ -363,10 +349,11 @@ async def test_form_only_still_sample( ), ], ) -async def test_still_template( +async def test_form_still_template( hass: HomeAssistant, user_flow: ConfigFlowResult, fakeimgbytes_png: bytes, + mock_setup_entry: Generator[AsyncMock], template, url, expected_result, @@ -380,12 +367,11 @@ async def test_still_template( data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template - with patch("homeassistant.components.generic.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["step_id"] == expected_result assert result2.get("errors") == expected_errors @@ -396,24 +382,19 @@ async def test_form_rtsp_mode( hass: HomeAssistant, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we complete ok if the user enters a stream url.""" data = TESTDATA.copy() data[CONF_RTSP_TRANSPORT] = "tcp" data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" - with ( - mock_create_stream as mock_setup, - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - ): - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], data - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result1 = await hass.config_entries.flow.async_configure(user_flow["flow_id"], data) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -428,8 +409,6 @@ async def test_form_rtsp_mode( CONF_VERIFY_SSL: False, } - assert len(mock_setup.mock_calls) == 1 - async def test_form_only_stream( hass: HomeAssistant, @@ -441,18 +420,16 @@ async def test_form_only_stream( data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_create_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) assert result1["type"] is FlowResultType.FORM - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -528,15 +505,11 @@ async def test_form_image_http_exceptions( mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle image http exceptions.""" - respx.get("http://127.0.0.1/testurl/1").side_effect = [ - side_effect, - ] - - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + respx.get("http://127.0.0.1/testurl/1").side_effect = [side_effect] + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == expected_message @@ -550,11 +523,10 @@ async def test_form_stream_invalidimage( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid") - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -568,11 +540,10 @@ async def test_form_stream_invalidimage2( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=None) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"} @@ -586,11 +557,10 @@ async def test_form_stream_invalidimage3( ) -> None: """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF])) - with mock_create_stream: - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -599,23 +569,22 @@ async def test_form_stream_invalidimage3( @respx.mock @pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_timeout( - hass: HomeAssistant, user_flow: ConfigFlowResult + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_create_stream: _patch[MagicMock], ) -> None: """Test we handle invalid auth.""" - with patch( - "homeassistant.components.generic.config_flow.create_stream" - ) as create_stream: - create_stream.return_value.start = AsyncMock() - create_stream.return_value.stop = AsyncMock() - create_stream.return_value.hass = hass - create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() - create_stream.return_value.add_provider.return_value.part_recv.return_value = ( - False - ) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) + mock_create_stream.return_value.start = AsyncMock() + mock_create_stream.return_value.stop = AsyncMock() + mock_create_stream.return_value.hass = hass + mock_create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() + mock_create_stream.return_value.add_provider.return_value.part_recv.return_value = ( + False + ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"stream_source": "timeout"} @@ -661,11 +630,11 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_permission_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, user_flow: ConfigFlowResult + hass: HomeAssistant, user_flow: ConfigFlowResult ) -> None: """Test we handle permission error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with patch( "homeassistant.components.generic.config_flow.create_stream", side_effect=PermissionError(), @@ -732,116 +701,73 @@ async def test_form_oserror(hass: HomeAssistant, user_flow: ConfigFlowResult) -> @respx.mock -async def test_form_stream_preview_auto_timeout( - hass: HomeAssistant, - user_flow: ConfigFlowResult, - mock_create_stream: _patch[MagicMock], - freezer: FrozenDateTimeFactory, - fakeimgbytes_png: bytes, -) -> None: - """Test that the stream preview times out after 10mins.""" - respx.get("http://fred_flintstone:bambam@127.0.0.1/testurl/2").respond( - stream=fakeimgbytes_png - ) - data = TESTDATA.copy() - data.pop(CONF_STILL_IMAGE_URL) - - with mock_create_stream as mock_stream: - result1 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm" - - freezer.tick(600 + 12) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - mock_str = mock_stream.return_value - mock_str.start.assert_awaited_once() - - -@respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_template_error( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_create_stream: _patch[MagicMock], + config_entry: MockConfigEntry, ) -> None: """Test the options flow with a template error.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) - - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(mock_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" # try updating the still image url data = TESTDATA.copy() data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user_confirm" + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_confirm" - result2a = await hass.config_entries.options.async_configure( - result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} - ) - assert result2a["type"] is FlowResultType.CREATE_ENTRY + result2a = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} + ) + assert result2a["type"] is FlowResultType.CREATE_ENTRY - result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "init" + result3 = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "init" - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input=data, - ) - assert result4.get("type") is FlowResultType.FORM - assert result4["errors"] == {"still_image_url": "template_error"} + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}" + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input=data, + ) + assert result4.get("type") is FlowResultType.FORM + assert result4["errors"] == {"still_image_url": "template_error"} - # verify that an invalid template reports the correct UI error. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" - result5 = await hass.config_entries.options.async_configure( - result4["flow_id"], - user_input=data, - ) + # verify that an invalid template reports the correct UI error. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://127.0.0.2/testurl/{{1/0}}" + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input=data, + ) - assert result5.get("type") is FlowResultType.FORM - assert result5["errors"] == {"stream_source": "template_error"} + assert result5.get("type") is FlowResultType.FORM + assert result5["errors"] == {"stream_source": "template_error"} - # verify that an relative stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" - result6 = await hass.config_entries.options.async_configure( - result5["flow_id"], - user_input=data, - ) - assert result6.get("type") is FlowResultType.FORM - assert result6["errors"] == {"stream_source": "relative_url"} + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") is FlowResultType.FORM + assert result6["errors"] == {"stream_source": "relative_url"} - # verify that an malformed stream url is rejected. - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" - data[CONF_STREAM_SOURCE] = "http://example.com:45:56" - result7 = await hass.config_entries.options.async_configure( - result6["flow_id"], - user_input=data, - ) + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) assert result7.get("type") is FlowResultType.FORM assert result7["errors"] == {"stream_source": "malformed_url"} @@ -861,11 +787,13 @@ async def test_slug(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_options_only_stream( - hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] + hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], + mock_create_stream: _patch[MagicMock], ) -> None: """Test the options flow without a still_image_url.""" - respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) @@ -883,11 +811,10 @@ async def test_options_only_stream( assert result["step_id"] == "init" # try updating the config options - with mock_create_stream: - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input=data, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=data, + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user_confirm" @@ -900,6 +827,7 @@ async def test_options_only_stream( async def test_options_still_and_stream_not_provided( hass: HomeAssistant, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test we show a suitable error if neither still or stream URL are provided.""" data = TESTDATA.copy() @@ -929,7 +857,7 @@ async def test_options_still_and_stream_not_provided( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_form_options_permission_error( +async def test_options_permission_error( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test we handle a PermissionError and pass the message through.""" @@ -947,43 +875,6 @@ async def test_form_options_permission_error( assert result2["errors"] == {"stream_source": "stream_not_permitted"} -@pytest.mark.usefixtures("fakeimg_png") -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test unloading the generic IP Camera entry.""" - mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_reload_on_title_change(hass: HomeAssistant) -> None: - """Test the integration gets reloaded when the title is updated.""" - - test_data = TESTDATA_OPTIONS - test_data[CONF_CONTENT_TYPE] = "image/png" - mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id="54321", options=test_data, title="My Title" - ) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - assert mock_entry.state is ConfigEntryState.LOADED - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "My Title" - - hass.config_entries.async_update_entry(mock_entry, title="New Title") - assert mock_entry.title == "New Title" - await hass.async_block_till_done() - - assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title" - - async def test_migrate_existing_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -1019,40 +910,26 @@ async def test_migrate_existing_ids( @respx.mock @pytest.mark.usefixtures("fakeimg_png") -async def test_use_wallclock_as_timestamps_option( +async def test_options_use_wallclock_as_timestamps( hass: HomeAssistant, mock_create_stream: _patch[MagicMock], hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, fakeimgbytes_png: bytes, + config_entry: MockConfigEntry, + mock_setup_entry: _patch[MagicMock], ) -> None: """Test the use_wallclock_as_timestamps option flow.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) - mock_entry = MockConfigEntry( - title="Test Camera", - domain=DOMAIN, - data={}, - options=TESTDATA, - ) - - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init( - mock_entry.entry_id, context={"show_advanced_options": True} + config_entry.entry_id, context={"show_advanced_options": True} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result2["type"] is FlowResultType.FORM ws_client = await hass_ws_client() @@ -1079,14 +956,10 @@ async def test_use_wallclock_as_timestamps_option( ) assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "init" - with ( - patch("homeassistant.components.generic.async_setup_entry", return_value=True), - mock_create_stream, - ): - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], - user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, - ) + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, + ) assert result4["type"] is FlowResultType.FORM assert result4["step_id"] == "user_confirm" result5 = await hass.config_entries.options.async_configure( diff --git a/tests/components/generic/test_init.py b/tests/components/generic/test_init.py new file mode 100644 index 00000000000..faa00ee9144 --- /dev/null +++ b/tests/components/generic/test_init.py @@ -0,0 +1,37 @@ +"""Define tests for the generic (IP camera) integration.""" + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("fakeimg_png") +async def test_unload_entry(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: + """Test unloading the generic IP Camera entry.""" + assert setup_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(setup_entry.entry_id) + await hass.async_block_till_done() + assert setup_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_reload_on_title_change( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test the integration gets reloaded when the title is updated.""" + assert setup_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] + == "Test Camera" + ) + + hass.config_entries.async_update_entry(setup_entry, title="New Title") + assert setup_entry.title == "New Title" + await hass.async_block_till_done() + + assert ( + hass.states.get("camera.test_camera").attributes["friendly_name"] == "New Title" + ) diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 33a8a0f37bd..3acb50fa38d 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.generic_hygrostat import ( DOMAIN as GENERIC_HYDROSTAT_DOMAIN, @@ -28,7 +29,6 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, @@ -40,7 +40,7 @@ from homeassistant.core import ( from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import StateType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 39435f154c4..7e2e92f025b 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.climate import ( ATTR_PRESET_MODE, @@ -35,7 +35,6 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, @@ -319,6 +318,20 @@ async def test_set_target_temp(hass: HomeAssistant) -> None: assert state.attributes.get("temperature") == 30.0 +@pytest.mark.usefixtures("setup_comp_2") +async def test_set_target_temp_change_preset(hass: HomeAssistant) -> None: + """Test the setting of the target temperature. + + Verify that preset is changed. + """ + await common.async_set_temperature(hass, 30) + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == PRESET_NONE + await common.async_set_temperature(hass, 20) + state = hass.states.get(ENTITY) + assert state.attributes.get("preset_mode") == PRESET_COMFORT + + @pytest.mark.parametrize( ("preset", "temp"), [ diff --git a/tests/components/geniushub/snapshots/test_binary_sensor.ambr b/tests/components/geniushub/snapshots/test_binary_sensor.ambr index fcc256b5232..c295ab8d10a 100644 --- a/tests/components/geniushub/snapshots/test_binary_sensor.ambr +++ b/tests/components/geniushub/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geniushub/snapshots/test_climate.ambr b/tests/components/geniushub/snapshots/test_climate.ambr index eb372de784e..8f897c84559 100644 --- a/tests/components/geniushub/snapshots/test_climate.ambr +++ b/tests/components/geniushub/snapshots/test_climate.ambr @@ -16,6 +16,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -96,6 +97,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +180,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +263,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +346,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -423,6 +428,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -503,6 +509,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geniushub/snapshots/test_sensor.ambr b/tests/components/geniushub/snapshots/test_sensor.ambr index 874f24cff95..aaf3030d4a4 100644 --- a/tests/components/geniushub/snapshots/test_sensor.ambr +++ b/tests/components/geniushub/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,6 +207,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -309,6 +315,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -415,6 +423,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -468,6 +477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -521,6 +531,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -629,6 +641,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,6 +697,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -739,6 +753,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -794,6 +809,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -849,6 +865,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -904,6 +921,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geniushub/snapshots/test_switch.ambr b/tests/components/geniushub/snapshots/test_switch.ambr index 6c3c95af477..cc0451b4e94 100644 --- a/tests/components/geniushub/snapshots/test_switch.ambr +++ b/tests/components/geniushub/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py index 11928e6f012..a4fff4563be 100644 --- a/tests/components/geo_json_events/conftest.py +++ b/tests/components/geo_json_events/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL from tests.common import MockConfigEntry diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py index fe21bccc7aa..9a52cb599b2 100644 --- a/tests/components/geo_json_events/test_config_flow.py +++ b/tests/components/geo_json_events/test_config_flow.py @@ -3,7 +3,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py index e90e663d8b6..0553190395d 100644 --- a/tests/components/geo_json_events/test_init.py +++ b/tests/components/geo_json_events/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import patch -from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,11 +24,11 @@ async def test_component_unload_config_entry( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][config_entry.entry_id] is not None + assert config_entry.state is ConfigEntryState.LOADED # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN].get(config_entry.entry_id) is None + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_remove_orphaned_entities( diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index d19262c3339..3b6ef8a0642 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import sensor -import homeassistant.components.geo_rss_events.sensor as geo_rss_events +from homeassistant.components.geo_rss_events import sensor as geo_rss_events from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 163bca775c9..fd8ba81fca7 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py index 82143baa374..2daeab9e7ef 100644 --- a/tests/components/geonetnz_quakes/test_sensor.py +++ b/tests/components/geonetnz_quakes/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index d6ebbcd6582..a79d8512df6 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 71e0afdc495..890edc00482 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '123', 'version': 1, diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index c67cc3e4d7c..ab8a2359d0c 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -73,6 +74,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -181,6 +184,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -243,6 +247,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -301,6 +306,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -421,6 +428,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -483,6 +491,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -541,6 +550,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -603,6 +613,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -661,6 +672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -723,6 +735,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr index 662e95c6a1c..baac4c5b056 100644 --- a/tests/components/glances/snapshots/test_sensor.ambr +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -107,6 +109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -317,6 +323,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -368,6 +375,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +430,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +485,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -530,6 +540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -584,6 +595,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -633,6 +645,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -682,6 +695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +745,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -780,6 +795,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -831,6 +847,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -881,6 +898,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -932,6 +950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -983,6 +1002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1033,6 +1053,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1084,6 +1105,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1135,6 +1157,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1185,6 +1208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1235,6 +1259,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,6 +1313,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1339,6 +1365,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1393,6 +1420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1447,6 +1475,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1501,6 +1530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1555,6 +1585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1606,6 +1637,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1656,6 +1688,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1705,6 +1738,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index 30a7c92510e..7d86f638fc2 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -2,11 +2,11 @@ from unittest.mock import AsyncMock, patch -from homeassistant.components import dhcp from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,7 +19,7 @@ CONF_DATA = { CONF_NAME: DEFAULT_NAME, } -CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( +CONF_DHCP_FLOW = DhcpServiceInfo( ip=HOST, macaddress=format_mac("AA:BB:CC:DD:EE:FF").replace(":", ""), hostname="yeti", diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 1d44c7e808e..4817be1ce35 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import CONF_DATA, async_init_integration, create_entry, create_mocked_yeti diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 25fb5922506..1e7e48437cd 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -8,7 +8,6 @@ from ismartgate.common import ApiError from ismartgate.const import GogoGate2ApiErrorCode from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.gogogate2.const import ( DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, @@ -23,6 +22,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import _mocked_ismartgate_closed_door_response @@ -105,13 +109,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -133,13 +137,13 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -158,13 +162,13 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -177,13 +181,13 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -213,7 +217,7 @@ async def test_discovered_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -260,13 +264,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC_ADDR}, type="mock_type", ), ) @@ -276,7 +280,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" ), ) @@ -286,7 +290,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" ), ) diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index f52e47688e8..40ed22195d5 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 6ce95a2bc17..3d10e753714 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( CALENDAR_ID, @@ -751,6 +751,7 @@ async def test_unique_id_migration( old_unique_id, ) -> None: """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, @@ -805,6 +806,7 @@ async def test_invalid_unique_id_cleanup( mock_calendars_yaml, ) -> None: """Test that old unique id format that is not actually unique is removed.""" + config_entry.add_to_hass(hass) # Create an entity using the old unique id format entity_registry.async_get_or_create( DOMAIN, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index edbbdb1ba28..1ecedbd1173 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'import', + 'subentries': list([ + ]), 'title': '1234', 'unique_id': '1234', 'version': 1, diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 0e6876cc901..a5451e5332d 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -316,7 +316,7 @@ async def test_sync_notifications(agents) -> None: config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT ) as mock: await config.async_sync_notification_all("1234", {}) - assert not agents or bool(mock.mock_calls) and agents + assert not agents or (bool(mock.mock_calls) and agents) @pytest.mark.parametrize( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index a1c2ba1b3d4..3b43728988b 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -51,7 +51,7 @@ from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import MockConfigEntry, async_capture_events +from tests.common import MockConfigEntry, MockEntityPlatform, async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -156,6 +156,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -301,6 +302,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = entity.entity_id light._attr_device_info = None light._attr_name = "Demo Light" @@ -396,6 +398,7 @@ async def test_query_message(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" @@ -405,6 +408,7 @@ async def test_query_message(hass: HomeAssistant) -> None: None, "Another Light", state=True, hs_color=(180, 75), ct=2500, brightness=78 ) light2.hass = hass + light2.platform = MockEntityPlatform(hass) light2.entity_id = "light.another_light" light2._attr_device_info = None light2._attr_name = "Another Light" @@ -412,6 +416,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) light3.hass = hass + light3.platform = MockEntityPlatform(hass) light3.entity_id = "light.color_temp_light" light3._attr_device_info = None light3._attr_name = "Color temp Light" @@ -899,6 +904,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: effect=LIGHT_EFFECT_LIST[0], ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._available = False light._attr_device_info = None @@ -996,6 +1002,7 @@ async def test_device_class_switch( device_class=device_class, ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "switch.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1046,6 +1053,7 @@ async def test_device_class_binary_sensor( None, "Demo Sensor", state=False, device_class=device_class ) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "binary_sensor.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1100,6 +1108,7 @@ async def test_device_class_cover( """Test that a cover entity syncs to the correct device type.""" sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "cover.demo_sensor" sensor._attr_device_info = None sensor._attr_name = "Demo Sensor" @@ -1150,6 +1159,7 @@ async def test_device_media_player( """Test that a binary entity syncs to the correct device type.""" sensor = AbstractDemoPlayer("Demo", device_class=device_class) sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) sensor.entity_id = "media_player.demo" sensor.async_write_ha_state() @@ -1441,6 +1451,7 @@ async def test_sync_message_recovery( hs_color=(180, 75), ) light.hass = hass + light.platform = MockEntityPlatform(hass) light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index d269b5ff0d7..dafe85d97b2 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -2208,7 +2208,7 @@ async def test_fan_speed_ordered( "ordered": True, "speeds": [ { - "speed_name": f"{idx+1}/{len(speeds)}", + "speed_name": f"{idx + 1}/{len(speeds)}", "speed_values": [{"lang": "en", "speed_synonym": x}], } for idx, x in enumerate(speeds) diff --git a/tests/components/google_drive/__init__.py b/tests/components/google_drive/__init__.py new file mode 100644 index 00000000000..7a55f70a3d6 --- /dev/null +++ b/tests/components/google_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Drive integration.""" diff --git a/tests/components/google_drive/conftest.py b/tests/components/google_drive/conftest.py new file mode 100644 index 00000000000..479412ddbe2 --- /dev/null +++ b/tests/components/google_drive/conftest.py @@ -0,0 +1,80 @@ +"""PyTest fixtures and test helpers.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +HA_UUID = "0a123c" +TEST_AGENT_ID = "google_drive.testuser_domain_com" +TEST_USER_EMAIL = "testuser@domain.com" +CONFIG_ENTRY_TITLE = "Google Drive entry title" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_api() -> Generator[MagicMock]: + """Return a mocked GoogleDriveApi.""" + with patch( + "homeassistant.components.google_drive.api.GoogleDriveApi" + ) as mock_api_cl: + mock_api = mock_api_cl.return_value + yield mock_api + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock instance_id.""" + with patch( + "homeassistant.components.google_drive.config_flow.instance_id.async_get", + return_value=HA_UUID, + ): + yield + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + title=CONFIG_ENTRY_TITLE, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": "https://www.googleapis.com/auth/drive.file", + }, + }, + ) diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr new file mode 100644 index 00000000000..891eb0e1cbe --- /dev/null +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -0,0 +1,239 @@ +# serializer version: 1 +# name: test_agents_delete + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'delete_file', + tuple( + 'backup-file-id', + ), + dict({ + }), + ), + ]) +# --- +# name: test_agents_download + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'get_file_content', + tuple( + 'backup-file-id', + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_list_backups + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + ]) +# --- +# name: test_agents_upload + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'resumable_upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test_2025-01-01_01.23_45678000.tar', + 'parents': list([ + 'HA folder ID', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + 987, + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_upload_create_folder_if_missing + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + tuple( + 'resumable_upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test_2025-01-01_01.23_45678000.tar', + 'parents': list([ + 'new folder id', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + 987, + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/snapshots/test_config_flow.ambr b/tests/components/google_drive/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..68e5416c5ec --- /dev/null +++ b/tests/components/google_drive/snapshots/test_config_flow.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_full_flow + list([ + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/test_application_credentials.py b/tests/components/google_drive/test_application_credentials.py new file mode 100644 index 00000000000..ec46db510a5 --- /dev/null +++ b/tests/components/google_drive/test_application_credentials.py @@ -0,0 +1,36 @@ +"""Test the Google Drive application_credentials.""" + +import pytest + +from homeassistant import setup +from homeassistant.components.google_drive.application_credentials import ( + async_get_description_placeholders, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("additional_components", "external_url", "expected_redirect_uri"), + [ + ([], "https://example.com", "https://example.com/auth/external/callback"), + ([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"), + (["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"), + ], +) +async def test_description_placeholders( + hass: HomeAssistant, + additional_components: list[str], + external_url: str | None, + expected_redirect_uri: str, +) -> None: + """Test description placeholders.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) + hass.config.external_url = external_url + placeholders = await async_get_description_placeholders(hass) + assert placeholders == { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": expected_redirect_uri, + } diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py new file mode 100644 index 00000000000..2da397def5b --- /dev/null +++ b/tests/components/google_drive/test_backup.py @@ -0,0 +1,464 @@ +"""Test the Google Drive backup platform.""" + +from io import StringIO +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientResponse +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, +) +from homeassistant.components.google_drive import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import mock_stream +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +FOLDER_ID = "google-folder-id" +TEST_AGENT_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="test-backup", + database_included=True, + date="2025-01-01T01:23:45.678Z", + extra_metadata={ + "with_automatic_settings": False, + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=987, +) +TEST_AGENT_BACKUP_RESULT = { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agents": {TEST_AGENT_ID: {"protected": False, "size": 987}}, + "backup_id": "test-backup", + "database_included": True, + "date": "2025-01-01T01:23:45.678Z", + "extra_metadata": {"with_automatic_settings": False}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "failed_agent_ids": [], + "with_automatic_settings": None, +} + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Set up Google Drive and backup integrations.""" + async_initialize_backup(hass) + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, + ], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent list backups.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_list_backups_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent list backups fails.""" + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == { + TEST_AGENT_ID: "Failed to list backups: some error" + } + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + (TEST_AGENT_BACKUP.backup_id, TEST_AGENT_BACKUP_RESULT), + ("12345", None), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent download backup.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(return_value=mock_response) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_download_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup fails.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to download backup" in content.decode() + + +async def test_agents_download_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": []}, + ] + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Backup not found" in content.decode() + + +async def test_agents_download_metadata_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_client() + backup_id = "1234" + assert backup_id != TEST_AGENT_BACKUP.backup_id + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 404 + assert await resp.content.read() == b"" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup.""" + mock_api.resumable_upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.resumable_upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_create_folder_if_missing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup creates folder if missing.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + mock_api.resumable_upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.create_file.assert_called_once() + mock_api.resumable_upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, +) -> None: + """Test agent upload backup fails.""" + mock_api.resumable_upload_file = AsyncMock( + side_effect=GoogleDriveApiError("some error") + ) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert "Failed to upload backup: some error" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent delete backup.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(return_value=None) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_delete_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup fails.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} + } + + +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup not found.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_not_called() diff --git a/tests/components/google_drive/test_config_flow.py b/tests/components/google_drive/test_config_flow.py new file mode 100644 index 00000000000..10f73d53a66 --- /dev/null +++ b/tests/components/google_drive/test_config_flow.py @@ -0,0 +1,363 @@ +"""Test the Google Drive config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import CLIENT_ID, TEST_USER_EMAIL + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +FOLDER_ID = "google-folder-id" +FOLDER_NAME = "folder name" +TITLE = "Google Drive" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": FOLDER_ID, "name": FOLDER_NAME} + ) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == TITLE + assert result.get("description_placeholders") == { + "folder_name": FOLDER_NAME, + "url": f"https://drive.google.com/drive/folders/{FOLDER_ID}", + } + assert "result" in result + assert result.get("result").unique_id == TEST_USER_EMAIL + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_create_folder_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test case where creating the folder fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "create_folder_failure" + assert result.get("description_placeholders") == {"message": "some error"} + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "expected_abort_reason", "expected_placeholders"), + [ + ( + GoogleDriveApiError("some error"), + "access_not_configured", + {"message": "some error"}, + ), + (Exception, "unknown", None), + ], + ids=["api_not_enabled", "general_exception"], +) +async def test_get_email_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + exception: Exception, + expected_abort_reason, + expected_placeholders, +) -> None: + """Test case where getting the email address fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(side_effect=exception) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ( + "new_email", + "expected_abort_reason", + "expected_placeholders", + "expected_access_token", + "expected_setup_calls", + ), + [ + (TEST_USER_EMAIL, "reauth_successful", None, "updated-access-token", 1), + ( + "other.user@domain.com", + "wrong_account", + {"email": TEST_USER_EMAIL}, + "mock-access-token", + 0, + ), + ], + ids=["reauth_successful", "wrong_account"], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + new_email: str, + expected_abort_reason: str, + expected_placeholders: dict[str, str] | None, + expected_access_token: str, + expected_setup_calls: int, +) -> None: + """Test the reauthentication flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(return_value={"user": {"emailAddress": new_email}}) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == expected_setup_calls + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + assert config_entry.unique_id == TEST_USER_EMAIL + assert "token" in config_entry.data + + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == expected_access_token + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test already configured account.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/google_drive/test_init.py b/tests/components/google_drive/test_init.py new file mode 100644 index 00000000000..8173e00fb54 --- /dev/null +++ b/tests/components/google_drive/test_init.py @@ -0,0 +1,164 @@ +"""Tests for Google Drive.""" + +from collections.abc import Awaitable, Callable, Coroutine +import http +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +type ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Callable[[], Coroutine[Any, Any, None]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return func + + +async def test_setup_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test successful setup and unload.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert entries[0].state is ConfigEntryState.NOT_LOADED + + +async def test_create_folder_if_missing( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test folder is created if missing.""" + # Setup looks up existing folder to make sure it still exists + # and creates it if missing + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_api.list_files.assert_called_once() + mock_api.create_file.assert_called_once() + + +async def test_setup_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test setup error.""" + # Simulate failure looking up existing folder + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test expired token is refreshed.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 8f789d9737e..6e2d37b035b 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -1 +1,31 @@ """Tests for the Google Generative AI Conversation integration.""" + +from unittest.mock import Mock + +from google.genai.errors import ClientError +import requests + +CLIENT_ERROR_500 = ClientError( + 500, + Mock( + __class__=requests.Response, + json=Mock( + return_value={ + "message": "Internal Server Error", + "status": "internal-error", + } + ), + ), +) +CLIENT_ERROR_API_KEY_INVALID = ClientError( + 400, + Mock( + __class__=requests.Response, + json=Mock( + return_value={ + "message": "'reason': API_KEY_INVALID", + "status": "unauthorized", + } + ), + ), +) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 28c21a9b791..2bc81b10ce4 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -1,7 +1,6 @@ """Tests helpers.""" -from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -15,14 +14,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_genai() -> Generator[None]: - """Mock the genai call in async_setup_entry.""" - with patch("google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.get_model"): - yield - - -@pytest.fixture -def mock_config_entry(hass: HomeAssistant, mock_genai: None) -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( domain="google_generative_ai_conversation", @@ -31,18 +23,21 @@ def mock_config_entry(hass: HomeAssistant, mock_genai: None) -> MockConfigEntry: "api_key": "bla", }, ) + entry.runtime_data = Mock() entry.add_to_hass(hass) return entry @pytest.fixture -def mock_config_entry_with_assist( +async def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} - ) + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + ) + await hass.async_block_till_done() return mock_config_entry @@ -51,8 +46,11 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: ConfigEntry ) -> None: """Initialize integration.""" - assert await async_setup_component(hass, "google_generative_ai_conversation", {}) - await hass.async_block_till_done() + with patch("google.genai.models.AsyncModels.get"): + assert await async_setup_component( + hass, "google_generative_ai_conversation", {} + ) + await hass.async_block_till_done() @pytest.fixture(autouse=True) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 65238c5212a..106366fd240 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -1,414 +1,4 @@ # serializer version: 1 -# name: test_chat_history[models/gemini-1.0-pro-False] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.0-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': None, - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'role': 'user', - }), - dict({ - 'parts': 'Ok', - 'role': 'model', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '1st user request', - ), - dict({ - }), - ), - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.0-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': None, - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'role': 'user', - }), - dict({ - 'parts': 'Ok', - 'role': 'model', - }), - dict({ - 'parts': '1st user request', - 'role': 'user', - }), - dict({ - 'parts': '1st model response', - 'role': 'model', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '2nd user request', - ), - dict({ - }), - ), - ]) -# --- -# name: test_chat_history[models/gemini-1.5-pro-True] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '1st user request', - ), - dict({ - }), - ), - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': '1st user request', - 'role': 'user', - }), - dict({ - 'parts': '1st model response', - 'role': 'model', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '2nd user request', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options0-0-None] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-flash-latest', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options0-0-conversation.google_generative_ai_conversation] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-flash-latest', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options1-1-None] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-flash-latest', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options1-1-conversation.google_generative_ai_conversation] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-flash-latest', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- # name: test_function_call list([ tuple( @@ -416,102 +6,26 @@ tuple( ), dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-flash-latest', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. - ''', - 'tools': list([ - function_declarations { - name: "test_tool" - description: "Test function" - parameters { - type_: OBJECT - properties { - key: "param3" - value { - type_: OBJECT - properties { - key: "json" - value { - type_: STRING - } - } - } - } - properties { - key: "param2" - value { - type_: NUMBER - } - } - properties { - key: "param1" - value { - type_: ARRAY - description: "Test parameters" - items { - type_: STRING - } - } - } - } - } - , - ]), - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), + 'model': 'models/gemini-2.0-flash', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - 'Please call the test function', ), dict({ + 'message': 'Please call the test function', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - parts { - function_response { - name: "test_tool" - response { - fields { - key: "result" - value { - string_value: "Test response" - } - } - } - } - } - , ), dict({ + 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), }), ), ]) @@ -523,71 +37,26 @@ tuple( ), dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-flash-latest', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. - ''', - 'tools': list([ - function_declarations { - name: "test_tool" - description: "Test function" - } - , - ]), - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=None)], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), + 'model': 'models/gemini-2.0-flash', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - 'Please call the test function', ), dict({ + 'message': 'Please call the test function', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - parts { - function_response { - name: "test_tool" - response { - fields { - key: "result" - value { - string_value: "Test response" - } - } - } - } - } - , ), dict({ + 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 316bf74b72a..b445499ad49 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-1.5-flash-latest', + 'chat_model': 'models/gemini-2.0-flash', 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f68f4c6bf14..8e6231cbffd 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -6,21 +6,12 @@ tuple( ), dict({ - 'model_name': 'models/gemini-1.5-flash-latest', - }), - ), - tuple( - '().generate_content_async', - tuple( - list([ + 'contents': list([ 'Describe this image from my doorbell camera', - dict({ - 'data': b'image bytes', - 'mime_type': 'image/jpeg', - }), + b'some file', + b'some file', ]), - ), - dict({ + 'model': 'models/gemini-2.0-flash', }), ), ]) @@ -32,17 +23,10 @@ tuple( ), dict({ - 'model_name': 'models/gemini-1.5-flash-latest', - }), - ), - tuple( - '().generate_content_async', - tuple( - list([ + 'contents': list([ 'Write an opening speech for a Home Assistant release party', ]), - ), - dict({ + 'model': 'models/gemini-2.0-flash', }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index d4992c732e1..30c9d6c46e6 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,10 +1,9 @@ """Test the Google Generative AI Conversation config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch -from google.api_core.exceptions import ClientError, DeadlineExceeded -from google.rpc.error_details_pb2 import ErrorInfo # pylint: disable=no-name-in-module import pytest +from requests.exceptions import Timeout from homeassistant import config_entries from homeassistant.components.google_generative_ai_conversation.config_flow import ( @@ -33,32 +32,47 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID + from tests.common import MockConfigEntry @pytest.fixture def mock_models(): """Mock the model list API.""" + model_20_flash = Mock( + display_name="Gemini 2.0 Flash", + supported_actions=["generateContent"], + ) + model_20_flash.name = "models/gemini-2.0-flash" + model_15_flash = Mock( display_name="Gemini 1.5 Flash", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_15_flash.name = "models/gemini-1.5-flash-latest" model_15_pro = Mock( display_name="Gemini 1.5 Pro", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_15_pro.name = "models/gemini-1.5-pro-latest" model_10_pro = Mock( display_name="Gemini 1.0 Pro", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_10_pro.name = "models/gemini-pro" + + async def models_pager(): + yield model_20_flash + yield model_15_flash + yield model_15_pro + yield model_10_pro + with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", - return_value=iter([model_15_flash, model_15_pro, model_10_pro]), + "google.genai.models.AsyncModels.list", + return_value=models_pager(), ): yield @@ -80,7 +94,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.list_models", + "google.genai.models.AsyncModels.list", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", @@ -164,7 +178,11 @@ async def test_options_switching( expected_options, ) -> None: """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, options=current_options + ) + await hass.async_block_till_done() options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) @@ -189,17 +207,15 @@ async def test_options_switching( ("side_effect", "error"), [ ( - ClientError("some error"), + CLIENT_ERROR_500, "cannot_connect", ), ( - DeadlineExceeded("deadline exceeded"), + Timeout("deadline exceeded"), "cannot_connect", ), ( - ClientError( - "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") - ), + CLIENT_ERROR_API_KEY_INVALID, "invalid_auth", ), (Exception, "unknown"), @@ -211,12 +227,7 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_client = AsyncMock() - mock_client.list_models.side_effect = side_effect - with patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient", - return_value=mock_client, - ): + with patch("google.genai.models.AsyncModels.list", side_effect=side_effect): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -253,7 +264,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.list_models", + "google.genai.models.AsyncModels.list", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 4192a60513e..5e887d3cab7 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -1,32 +1,28 @@ """Tests for the Google Generative AI Conversation integration conversation platform.""" from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time -from google.ai.generativelanguage_v1beta.types.content import FunctionCall -from google.api_core.exceptions import GoogleAPIError -import google.generativeai.types as genai_types +from google.genai.types import FunctionCall import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import trace -from homeassistant.components.google_generative_ai_conversation.const import ( - CONF_CHAT_MODEL, -) from homeassistant.components.google_generative_ai_conversation.conversation import ( _escape_decode, _format_schema, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm +from . import CLIENT_ERROR_500 + from tests.common import MockConfigEntry -from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) @@ -36,147 +32,18 @@ def freeze_the_time(): yield -@pytest.mark.parametrize( - "agent_id", [None, "conversation.google_generative_ai_conversation"] -) -@pytest.mark.parametrize( - ("config_entry_options", "expected_features"), - [ - ({}, 0), - ( - {CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, - conversation.ConversationEntityFeature.CONTROL, - ), - ], -) -@pytest.mark.usefixtures("mock_init_component") -async def test_default_prompt( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - agent_id: str | None, - config_entry_options: {}, - expected_features: conversation.ConversationEntityFeature, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test that the default prompt works.""" - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - - if agent_id is None: - agent_id = mock_config_entry.entry_id - - hass.config_entries.async_update_entry( - mock_config_entry, - options={**mock_config_entry.options, **config_entry_options}, - ) - - with ( - patch("google.generativeai.GenerativeModel") as mock_model, - patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools", - return_value=[], - ) as mock_get_tools, - patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_api_prompt", - return_value="", - ), - ): - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() - mock_part.function_call = None - mock_part.text = "Hi there!\n" - chat_response.parts = [mock_part] - result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id=agent_id, - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot - assert mock_get_tools.called == (CONF_LLM_HASS_API in config_entry_options) - - state = hass.states.get("conversation.google_generative_ai_conversation") - assert state.attributes[ATTR_SUPPORTED_FEATURES] == expected_features - - -@pytest.mark.parametrize( - ("model_name", "supports_system_instruction"), - [("models/gemini-1.5-pro", True), ("models/gemini-1.0-pro", False)], -) -@pytest.mark.usefixtures("mock_init_component") -async def test_chat_history( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - model_name: str, - supports_system_instruction: bool, - snapshot: SnapshotAssertion, -) -> None: - """Test that the agent keeps track of the chat history.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_CHAT_MODEL: model_name} - ) - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() - mock_part.function_call = None - mock_part.text = "1st model response" - chat_response.parts = [mock_part] - if supports_system_instruction: - mock_chat.history = [] - else: - mock_chat.history = [ - {"role": "user", "parts": "prompt"}, - {"role": "model", "parts": "Ok"}, - ] - mock_chat.history += [ - {"role": "user", "parts": "1st user request"}, - {"role": "model", "parts": "1st model response"}, - ] - result = await conversation.async_converse( - hass, - "1st user request", - None, - Context(), - agent_id=mock_config_entry.entry_id, - ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - result.response.as_dict()["speech"]["plain"]["speech"] - == "1st model response" - ) - mock_part.text = "2nd model response" - chat_response.parts = [mock_part] - result = await conversation.async_converse( - hass, - "2nd user request", - result.conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - result.response.as_dict()["speech"]["plain"]["speech"] - == "2nd model response" - ) - - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot +@pytest.fixture(autouse=True) +def mock_ulid_tools(): + """Mock generated ULIDs for tool calls.""" + with patch("homeassistant.helpers.llm.ulid_now", return_value="mock-tool-call"): + yield @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" ) @pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -184,7 +51,7 @@ async def test_function_call( snapshot: SnapshotAssertion, ) -> None: """Test function calling.""" - agent_id = mock_config_entry_with_assist.entry_id + agent_id = "conversation.google_generative_ai_conversation" context = Context() mock_tool = AsyncMock() @@ -202,12 +69,13 @@ async def test_function_call( mock_get_tools.return_value = [mock_tool] - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() + mock_part.text = "" mock_part.function_call = FunctionCall( name="test_tool", args={ @@ -224,7 +92,7 @@ async def test_function_call( return {"result": "Test response"} mock_tool.async_call.side_effect = tool_call - chat_response.parts = [mock_part] + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] result = await conversation.async_converse( hass, "Please call the test function", @@ -236,25 +104,34 @@ async def test_function_call( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] - mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) - assert mock_tool_call == { + mock_tool_call = mock_create.mock_calls[2][2]["message"] + assert mock_tool_call.model_dump() == { "parts": [ { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, "function_response": { + "id": None, "name": "test_tool", "response": { "result": "Test response", }, }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, }, ], - "role": "", + "role": None, } mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={ "param1": ["test_value", "param1's value"], @@ -270,7 +147,7 @@ async def test_function_call( device_id="test_device", ), ) - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot # Test conversating tracing traces = trace.async_get_traces() @@ -284,8 +161,10 @@ async def test_function_call( ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["prompt"] - assert [t.name for t in detail_event["data"]["tools"]] == ["test_tool"] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + assert [ + p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] + ] == ["test_tool"] @patch( @@ -299,7 +178,7 @@ async def test_function_call_without_parameters( snapshot: SnapshotAssertion, ) -> None: """Test function calling without parameters.""" - agent_id = mock_config_entry_with_assist.entry_id + agent_id = "conversation.google_generative_ai_conversation" context = Context() mock_tool = AsyncMock() @@ -309,12 +188,13 @@ async def test_function_call_without_parameters( mock_get_tools.return_value = [mock_tool] - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={}) def tool_call( @@ -325,7 +205,7 @@ async def test_function_call_without_parameters( return {"result": "Test response"} mock_tool.async_call.side_effect = tool_call - chat_response.parts = [mock_part] + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] result = await conversation.async_converse( hass, "Please call the test function", @@ -337,25 +217,34 @@ async def test_function_call_without_parameters( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] - mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) - assert mock_tool_call == { + mock_tool_call = mock_create.mock_calls[2][2]["message"] + assert mock_tool_call.model_dump() == { "parts": [ { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, "function_response": { + "id": None, "name": "test_tool", "response": { "result": "Test response", }, }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, }, ], - "role": "", + "role": None, } mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={}, ), @@ -368,7 +257,7 @@ async def test_function_call_without_parameters( device_id="test_device", ), ) - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot @patch( @@ -381,7 +270,7 @@ async def test_function_exception( mock_config_entry_with_assist: MockConfigEntry, ) -> None: """Test exception in function calling.""" - agent_id = mock_config_entry_with_assist.entry_id + agent_id = "conversation.google_generative_ai_conversation" context = Context() mock_tool = AsyncMock() @@ -397,12 +286,13 @@ async def test_function_exception( mock_get_tools.return_value = [mock_tool] - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) def tool_call( @@ -413,7 +303,7 @@ async def test_function_exception( raise HomeAssistantError("Test tool exception") mock_tool.async_call.side_effect = tool_call - chat_response.parts = [mock_part] + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] result = await conversation.async_converse( hass, "Please call the test function", @@ -425,25 +315,34 @@ async def test_function_exception( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] - mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) - assert mock_tool_call == { + mock_tool_call = mock_create.mock_calls[2][2]["message"] + assert mock_tool_call.model_dump() == { "parts": [ { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, "function_response": { + "id": None, "name": "test_tool", "response": { "error": "HomeAssistantError", "error_text": "Test tool exception", }, }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, }, ], - "role": "", + "role": None, } mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={"param1": 1}, ), @@ -463,18 +362,22 @@ async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that client errors are caught.""" - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = GoogleAPIError("some error") + mock_create.return_value.send_message = mock_chat + mock_chat.side_effect = CLIENT_ERROR_500 result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem talking to Google Generative AI: some error" + "Sorry, I had a problem talking to Google Generative AI: 500 internal-error. {'message': 'Internal Server Error', 'status': 'internal-error'}" ) @@ -483,20 +386,24 @@ async def test_blocked_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test blocked response.""" - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = genai_types.StopCandidateException( - "finish_reason: SAFETY\n" - ) + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=Mock(block_reason_message="SAFETY")) + mock_chat.return_value = chat_response + result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "The message got blocked by your safety settings" + "The message got blocked due to content violations, reason: SAFETY" ) @@ -505,14 +412,18 @@ async def test_empty_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test empty response.""" - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - chat_response.parts = [] + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = [Mock(content=Mock(parts=[]))] result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -523,96 +434,32 @@ async def test_empty_response( @pytest.mark.usefixtures("mock_init_component") -async def test_invalid_llm_api( +async def test_converse_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test handling of invalid llm api.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, - ) + """Test handling ChatLog raising ConverseError.""" + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, + options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, + ) + await hass.async_block_till_done() result = await conversation.async_converse( hass, "hello", None, Context(), - agent_id=mock_config_entry.entry_id, + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Error preparing LLM API: API invalid_llm_api not found" + "Error preparing LLM API" ) -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with patch("google.generativeai.GenerativeModel"): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_variables( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - context = Context(user_id="12345") - mock_user = MagicMock() - mock_user.id = "12345" - mock_user.name = "Test User" - - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": ( - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." - ), - }, - ) - with ( - patch("google.generativeai.GenerativeModel") as mock_model, - patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() - mock_part.text = "Model response" - chat_response.parts = [mock_part] - result = await conversation.async_converse( - hass, "hello", None, context, agent_id=mock_config_entry.entry_id - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert ( - "The user name is Test User." - in mock_model.mock_calls[0][2]["system_instruction"] - ) - assert "The user id is 12345." in mock_model.mock_calls[0][2]["system_instruction"] - - @pytest.mark.usefixtures("mock_init_component") async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -640,31 +487,75 @@ async def test_escape_decode() -> None: @pytest.mark.parametrize( - ("openapi", "protobuf"), + ("openapi", "genai_schema"), [ ( {"type": "string", "enum": ["a", "b", "c"]}, - {"type_": "STRING", "enum": ["a", "b", "c"]}, + {"type": "STRING", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, + {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "date-time"}, + {"type": "STRING", "format": "date-time"}, + ), + ( + {"type": "string", "format": "byte"}, + {"type": "STRING"}, + ), + ( + {"type": "number", "format": "float"}, + {"type": "NUMBER", "format": "float"}, + ), + ( + {"type": "number", "format": "double"}, + {"type": "NUMBER", "format": "double"}, + ), + ( + {"type": "number", "format": "hex"}, + {"type": "NUMBER"}, + ), + ( + {"type": "integer", "format": "int32"}, + {"type": "INTEGER", "format": "int32"}, + ), + ( + {"type": "integer", "format": "int64"}, + {"type": "INTEGER", "format": "int64"}, + ), + ( + {"type": "integer", "format": "int8"}, + {"type": "INTEGER"}, ), ( {"type": "integer", "enum": [1, 2, 3]}, - {"type_": "STRING", "enum": ["1", "2", "3"]}, + {"type": "STRING", "enum": ["1", "2", "3"]}, + ), + ( + {"anyOf": [{"type": "integer"}, {"type": "number"}]}, + {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, ), - ({"anyOf": [{"type": "integer"}, {"type": "number"}]}, {"type_": "INTEGER"}), ( { - "anyOf": [ - {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"anyOf": [{"type": "integer"}, {"type": "number"}]}, + "any_of": [ + {"any_of": [{"type": "integer"}, {"type": "number"}]}, + {"any_of": [{"type": "integer"}, {"type": "number"}]}, + ] + }, + { + "any_of": [ + {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, + {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, ] }, - {"type_": "INTEGER"}, ), - ({"type": "string", "format": "lower"}, {"type_": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"type_": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type_": "NUMBER", "format_": "percent"}, + {"type": "NUMBER"}, ), ( { @@ -673,25 +564,25 @@ async def test_escape_decode() -> None: "required": [], }, { - "type_": "OBJECT", - "properties": {"var": {"type_": "STRING"}}, + "type": "OBJECT", + "properties": {"var": {"type": "STRING"}}, "required": [], }, ), ( {"type": "object", "additionalProperties": True}, { - "type_": "OBJECT", - "properties": {"json": {"type_": "STRING"}}, + "type": "OBJECT", + "properties": {"json": {"type": "STRING"}}, "required": [], }, ), ( {"type": "array", "items": {"type": "string"}}, - {"type_": "ARRAY", "items": {"type_": "STRING"}}, + {"type": "ARRAY", "items": {"type": "STRING"}}, ), ], ) -async def test_format_schema(openapi, protobuf) -> None: +async def test_format_schema(openapi, genai_schema) -> None: """Test _format_schema.""" - assert _format_schema(openapi) == protobuf + assert _format_schema(openapi) == genai_schema diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 4875323d094..0dad485812e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,16 +1,17 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch -from google.api_core.exceptions import ClientError, DeadlineExceeded -from google.rpc.error_details_pb2 import ErrorInfo # pylint: disable=no-name-in-module import pytest +from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID + from tests.common import MockConfigEntry @@ -24,12 +25,14 @@ async def test_generate_content_service_without_images( "party for the latest version of Home Assistant!" ) - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_response = MagicMock() - mock_response.text = stubbed_generated_content - mock_model.return_value.generate_content_async = AsyncMock( - return_value=mock_response - ) + with patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate: response = await hass.services.async_call( "google_generative_ai_conversation", "generate_content", @@ -41,7 +44,7 @@ async def test_generate_content_service_without_images( assert response == { "text": stubbed_generated_content, } - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot @pytest.mark.usefixtures("mock_init_component") @@ -54,25 +57,27 @@ async def test_generate_content_service_with_image( ) with ( - patch("google.generativeai.GenerativeModel") as mock_model, patch( - "homeassistant.components.google_generative_ai_conversation.Path.read_bytes", - return_value=b"image bytes", + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate, + patch( + "google.genai.files.Files.upload", + return_value=b"some file", ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), ): - mock_response = MagicMock() - mock_response.text = stubbed_generated_content - mock_model.return_value.generate_content_async = AsyncMock( - return_value=mock_response - ) response = await hass.services.async_call( "google_generative_ai_conversation", "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], }, blocking=True, return_response=True, @@ -81,7 +86,7 @@ async def test_generate_content_service_with_image( assert response == { "text": stubbed_generated_content, } - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot @pytest.mark.usefixtures("mock_init_component") @@ -90,20 +95,23 @@ async def test_generate_content_service_error( mock_config_entry: MockConfigEntry, ) -> None: """Test generate content service handles errors.""" - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_model.return_value.generate_content_async = AsyncMock( - side_effect=ClientError("reason") + with ( + patch( + "google.genai.models.AsyncModels.generate_content", + side_effect=CLIENT_ERROR_500, + ), + pytest.raises( + HomeAssistantError, + match="Error generating content: 500 internal-error. {'message': 'Internal Server Error', 'status': 'internal-error'}", + ), + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, ) - with pytest.raises( - HomeAssistantError, match="Error generating content: None reason" - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, - ) @pytest.mark.usefixtures("mock_init_component") @@ -113,21 +121,22 @@ async def test_generate_content_response_has_empty_parts( ) -> None: """Test generate content service handles response with empty parts.""" with ( - patch("google.generativeai.GenerativeModel") as mock_model, + patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + prompt_feedback=None, + candidates=[Mock(content=Mock(parts=[]))], + ), + ), + pytest.raises(HomeAssistantError, match="Unknown error generating content"), ): - mock_response = MagicMock() - mock_response.parts = [] - mock_model.return_value.generate_content_async = AsyncMock( - return_value=mock_response + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, ) - with pytest.raises(HomeAssistantError, match="Error generating content"): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, - ) @pytest.mark.usefixtures("mock_init_component") @@ -152,7 +161,7 @@ async def test_generate_content_service_with_image_not_allowed_path( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, @@ -177,30 +186,7 @@ async def test_generate_content_service_with_image_not_exists( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_non_image(hass: HomeAssistant) -> None: - """Test generate content service with a non image.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("pathlib.Path.exists", return_value=True), - pytest.raises( - HomeAssistantError, match="`doorbell_snapshot.mp4` is not an image" - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.mp4", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, @@ -211,19 +197,17 @@ async def test_generate_content_service_with_non_image(hass: HomeAssistant) -> N ("side_effect", "state", "reauth"), [ ( - ClientError("some error"), + CLIENT_ERROR_500, ConfigEntryState.SETUP_ERROR, False, ), ( - DeadlineExceeded("deadline exceeded"), + Timeout, ConfigEntryState.SETUP_RETRY, False, ), ( - ClientError( - "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") - ), + CLIENT_ERROR_API_KEY_INVALID, ConfigEntryState.SETUP_ERROR, True, ), @@ -235,10 +219,7 @@ async def test_config_entry_error( """Test different configuration entry errors.""" mock_client = AsyncMock() mock_client.get_model.side_effect = side_effect - with patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient", - return_value=mock_client, - ): + with patch("google.genai.models.AsyncModels.get", side_effect=side_effect): assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state == state diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 6f2f1a4ec32..e9dd2da85de 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.google_mail.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import SENSOR, TOKEN, ComponentSetup diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 381fb1c431f..e02253be887 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -74,24 +74,55 @@ def mock_upload_file( yield +@pytest.mark.parametrize( + ("media_items_result", "service_response"), + [ + ( + CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ) + ] + ), + [{"media_item_id": "new-media-item-id-1"}], + ), + ( + CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ), + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-2"), + ), + ] + ), + [ + {"media_item_id": "new-media-item-id-1"}, + {"media_item_id": "new-media-item-id-2"}, + ], + ), + ], +) @pytest.mark.usefixtures("setup_integration") async def test_upload_service( hass: HomeAssistant, config_entry: MockConfigEntry, mock_api: Mock, + media_items_result: CreateMediaItemsResult, + service_response: list[dict[str, str]], ) -> None: """Test service call to upload content.""" assert hass.services.has_service(DOMAIN, "upload") - mock_api.create_media_items.return_value = CreateMediaItemsResult( - new_media_item_results=[ - NewMediaItemResult( - upload_token="some-upload-token", - status=Status(code=200), - media_item=MediaItem(id="new-media-item-id-1"), - ) - ] - ) + mock_api.create_media_items.return_value = media_items_result response = await hass.services.async_call( DOMAIN, @@ -106,7 +137,7 @@ async def test_upload_service( ) assert response == { - "media_items": [{"media_item_id": "new-media-item-id-1"}], + "media_items": service_response, "album_id": "album-media-id-1", } diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 5b691da4bdc..54ad47405a1 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -475,6 +475,6 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index af870a2136d..88adcbf6587 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -7,12 +7,16 @@ from unittest.mock import Mock, patch import requests_mock -import homeassistant.components.google_wifi.sensor as google_wifi +from homeassistant.components.google_wifi import sensor as google_wifi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import ( + MockEntityPlatform, + assert_setup_component, + async_fire_time_changed, +) NAME = "foo" @@ -111,11 +115,12 @@ def fake_delay(hass: HomeAssistant, ha_delay: int) -> None: async_fire_time_changed(hass, shifted_time) -def test_name(requests_mock: requests_mock.Mocker) -> None: +def test_name(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the name.""" api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] + sensor.platform = MockEntityPlatform(hass) test_name = value["name"] assert test_name == sensor.name diff --git a/tests/components/govee_ble/test_config_flow.py b/tests/components/govee_ble/test_config_flow.py index eb0719f832c..ac8970ca977 100644 --- a/tests/components/govee_ble/test_config_flow.py +++ b/tests/components/govee_ble/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" +async def test_async_step_user_replace_ignored_device(hass: HomeAssistant) -> None: + """Test setup user step can replace an ignored device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=GVH5177_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.govee_ble.config_flow.async_discovered_service_info", + return_value=[GVH5177_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.govee_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "H5177 2EC8" + assert result2["data"] == {CONF_DEVICE_TYPE: "H5177"} + assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 6a8ee99b764..a8b6955c384 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,14 +4,15 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapability +from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures +from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @pytest.fixture(name="mock_govee_api") -def fixture_mock_govee_api(): +def fixture_mock_govee_api() -> Generator[AsyncMock]: """Set up Govee Local API fixture.""" mock_api = AsyncMock(spec=GoveeController) mock_api.start = AsyncMock() @@ -20,8 +21,20 @@ def fixture_mock_govee_api(): mock_api.turn_on_off = AsyncMock() mock_api.set_brightness = AsyncMock() mock_api.set_color = AsyncMock() + mock_api.set_scene = AsyncMock() mock_api._async_update_data = AsyncMock() - return mock_api + + with ( + patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_api, + ) as mock_controller, + patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_api, + ), + ): + yield mock_controller.return_value @pytest.fixture(name="mock_setup_entry") @@ -34,8 +47,12 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { - GoveeLightCapability.COLOR_RGB, - GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, - GoveeLightCapability.BRIGHTNESS, -} +DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES, segments=[], scenes={} +) + +SCENE_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES | GoveeLightFeatures.SCENES, + segments=[], + scenes=SCENE_CODES, +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 2e7144fae3a..e6e336a70f2 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.govee_light_local.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: @@ -20,7 +20,7 @@ def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -32,15 +32,9 @@ async def test_creating_entry_has_no_devices( mock_govee_api.devices = [] - with ( - patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ), - patch( - "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", - 0, - ), + with patch( + "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", + 0, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -67,24 +61,20 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() - mock_govee_api.start.assert_awaited_once() - mock_setup_entry.assert_awaited_once() + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() async def test_creating_entry_errno( @@ -99,21 +89,17 @@ async def test_creating_entry_errno( mock_govee_api.start.side_effect = e mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT - await hass.async_block_till_done() + await hass.async_block_till_done() - assert mock_govee_api.start.call_count == 1 - mock_setup_entry.assert_not_awaited() + assert mock_govee_api.start.call_count == 1 + mock_setup_entry.assert_not_awaited() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 4a1125643fa..c5dde6a9b9e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES from tests.common import MockConfigEntry @@ -26,32 +26,28 @@ async def test_light_known_device( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None + light = hass.states.get("light.H615A") + assert light is not None - color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] - assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} - # Remove - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is None + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None async def test_light_unknown_device( @@ -69,26 +65,22 @@ async def test_light_unknown_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.XYZK") - assert light is not None + light = hass.states.get("light.XYZK") + assert light is not None - assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: - """Test adding a known device.""" + """Test remove device.""" mock_govee_api.devices = [ GoveeDevice( @@ -96,53 +88,45 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is not None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is not None - # Remove 1 - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 async def test_light_setup_retry( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup retry.""" mock_govee_api.devices = [] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - with patch( - "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", - 0, - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + with patch( + "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", + 0, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_retry_eaddrinuse( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test retry on address already in use.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = EADDRINUSE @@ -152,25 +136,21 @@ async def test_light_setup_retry_eaddrinuse( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_error( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup error.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = ENETDOWN @@ -180,23 +160,19 @@ async def test_light_setup_error( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: - """Test adding a known device.""" + """Test light on and then off.""" mock_govee_api.devices = [ GoveeDevice( @@ -204,52 +180,48 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) - # Turn off - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -260,71 +232,63 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness_pct": 50}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -335,58 +299,316 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, - blocking=True, + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + ) + + +async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turning on scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) - assert light.attributes["color_mode"] == ColorMode.RGB + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + +async def test_scene_restore_rgb( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore rgb color.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "kelvin": 4400}, - blocking=True, + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + + +async def test_scene_restore_temperature( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore color temperature.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["color_temp_kelvin"] == 4400 - assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=None, temperature=4400 + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = 3456 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == initial_color + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["color_temp_kelvin"] == initial_color + + +async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turn on 'none' scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + mock_govee_api.set_scene.assert_not_called() diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 4f62be5cded..9111b909f04 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -93,6 +93,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index 71c6d3ea71d..836641cb2ab 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -71,6 +71,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -195,6 +199,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index ae2f0c74236..acfa1ba43f5 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -12,7 +12,7 @@ from homeassistant.components.gree.const import ( UPDATE_INTERVAL, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_setup_gree, build_device_mock diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 0cb187f5a60..d7c011a4c25 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -52,6 +52,7 @@ from homeassistant.components.gree.const import ( DISCOVERY_SCAN_INTERVAL, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.const import ( @@ -346,7 +347,7 @@ async def test_unresponsive_device( await async_setup_gree(hass) async def run_update(): - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index b1f622569bd..ab92b18cc91 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 91604d663b3..dbd74e95780 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -6,8 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config as hass_config -from homeassistant.components.group import DOMAIN, SERVICE_RELOAD -import homeassistant.components.group.light as group +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD, light as group from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index de406cb251c..187991141e7 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -35,8 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 6c06171a45f..5f0d54aaa0d 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch from aioguardian.errors import GuardianError import pytest -from homeassistant.components import dhcp, zeroconf from homeassistant.components.guardian import CONF_UID, DOMAIN from homeassistant.components.guardian.config_flow import ( async_get_pin_from_discovery_hostname, @@ -17,6 +16,8 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCO from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -82,7 +83,7 @@ async def test_step_user(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf step.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -112,7 +113,7 @@ async def test_step_zeroconf(hass: HomeAssistant) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - zeroconf_data = zeroconf.ZeroconfServiceInfo( + zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], port=7777, @@ -138,7 +139,7 @@ async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_guardian") async def test_step_dhcp(hass: HomeAssistant) -> None: """Test the dhcp step.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -164,7 +165,7 @@ async def test_step_dhcp(hass: HomeAssistant) -> None: async def test_step_dhcp_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddeeff", @@ -193,7 +194,7 @@ async def test_step_dhcp_already_setup_match_mac(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", @@ -215,7 +216,7 @@ async def test_step_dhcp_already_setup_match_ip(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="192.168.1.100", hostname="GVC1-ABCD.local.", macaddress="aabbccddabcd", diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index faba2103000..4487d0b6ac6 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -42,6 +42,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "valve_controller": { diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png new file mode 100644 index 00000000000..5bb8c9d9f09 Binary files /dev/null and b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png differ diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png new file mode 100644 index 00000000000..8e9b046ee05 Binary files /dev/null and b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png differ diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 935a203f993..efb4f7300bf 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from habiticalib import ( BadRequestError, + HabiticaCastSkillResponse, HabiticaContentResponse, HabiticaErrorResponse, HabiticaGroupMembersResponse, @@ -13,10 +14,11 @@ from habiticalib import ( HabiticaResponse, HabiticaScoreResponse, HabiticaSleepResponse, + HabiticaTagResponse, HabiticaTaskOrderResponse, HabiticaTaskResponse, HabiticaTasksResponse, - HabiticaUserAnonymizedrResponse, + HabiticaUserAnonymizedResponse, HabiticaUserResponse, NotAuthorizedError, NotFoundError, @@ -31,11 +33,13 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="message") +ERROR_RESPONSE = HabiticaErrorResponse(success=False, error="error", message="reason") ERROR_NOT_AUTHORIZED = NotAuthorizedError(error=ERROR_RESPONSE, headers={}) ERROR_NOT_FOUND = NotFoundError(error=ERROR_RESPONSE, headers={}) ERROR_BAD_REQUEST = BadRequestError(error=ERROR_RESPONSE, headers={}) -ERROR_TOO_MANY_REQUESTS = TooManyRequestsError(error=ERROR_RESPONSE, headers={}) +ERROR_TOO_MANY_REQUESTS = TooManyRequestsError( + error=ERROR_RESPONSE, headers={"retry-after": 5} +) @pytest.fixture(name="config_entry") @@ -91,8 +95,8 @@ async def mock_habiticalib() -> Generator[AsyncMock]: load_fixture("user.json", DOMAIN) ) - client.cast_skill.return_value = HabiticaUserResponse.from_json( - load_fixture("user.json", DOMAIN) + client.cast_skill.return_value = HabiticaCastSkillResponse.from_json( + load_fixture("cast_skill_response.json", DOMAIN) ) client.toggle_sleep.return_value = HabiticaSleepResponse( success=True, data=True @@ -137,10 +141,19 @@ async def mock_habiticalib() -> Generator[AsyncMock]: {"data": [], "success": True} ) client.get_user_anonymized.return_value = ( - HabiticaUserAnonymizedrResponse.from_json( + HabiticaUserAnonymizedResponse.from_json( load_fixture("anonymized.json", DOMAIN) ) ) + client.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.create_tag.return_value = HabiticaTagResponse.from_json( + load_fixture("create_tag.json", DOMAIN) + ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/fixtures/cast_skill_response.json b/tests/components/habitica/fixtures/cast_skill_response.json new file mode 100644 index 00000000000..41880770394 --- /dev/null +++ b/tests/components/habitica/fixtures/cast_skill_response.json @@ -0,0 +1,216 @@ +{ + "success": true, + "data": { + "user": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "auth": { "local": { "username": "test-username" } }, + "stats": { + "buffs": { + "str": 26, + "int": 26, + "per": 26, + "con": 26, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false, + "language": "en" + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + }, + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "tags": [ + { + "id": "8515e4ae-2f4b-455a-b4a4-8939e04b1bfd", + "name": "Arbeit" + }, + { + "id": "6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab", + "name": "Training" + }, + { + "id": "20409521-c096-447f-9a90-23e8da615710", + "name": "Gesundheit + Wohlbefinden" + }, + { + "id": "2ac458af-0833-4f3f-bf04-98a0c33ef60b", + "name": "Schule" + }, + { + "id": "1bcb1a0f-4d05-4087-8223-5ea779e258b0", + "name": "Teams" + }, + { + "id": "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb", + "name": "Hausarbeiten" + }, + { + "id": "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "name": "Kreativität" + } + ], + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z", + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "back_special_heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "eyewear_armoire_plagueDoctorMask", + "body": "body_special_aetherAmulet" + } + } + }, + "balance": 10 + }, + "task": { + "_id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "date": "2024-08-31T22:16:00.000Z", + "completed": false, + "collapseChecklist": false, + "checklist": [], + "type": "todo", + "text": "Rechnungen bezahlen", + "notes": "Strom- und Internetrechnungen rechtzeitig überweisen.", + "tags": [], + "value": 0, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "reminders": [ + { + "id": "91c09432-10ac-4a49-bd20-823081ec29ed", + "time": "2024-09-22T02:00:00.0000Z" + } + ], + "byHabitica": false, + "createdAt": "2024-09-21T22:17:19.513Z", + "updatedAt": "2024-09-21T22:19:35.576Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "alias": "pay_bills" + } + }, + "notifications": [ + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_habitoween_base_pet", + "title": "Happy Habitoween!", + "text": "For this spooky celebration, you've received a Jack-O-Lantern Pet and an assortment of candy for your Pets!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "5af98f52-f72a-4540-bdeb-3ffc39b34196" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "icon": "notif_harvestfeast_base_pet", + "title": "Happy Harvest Feast!", + "text": "Gobble gobble, you've received the Turkey Pet!", + "destination": "/inventory/stable" + }, + "seen": false, + "id": "1e4a1481-e7ca-42d1-9b3f-3f442bef9435" + }, + { + "type": "ITEM_RECEIVED", + "data": { + "title": "Happy New Year!", + "destination": "/inventory/equipment", + "icon": "notif_2013hat_nye", + "text": "Take on your resolutions with style in this Absurd Party Hat!" + }, + "seen": false, + "id": "61fe229b-feee-48b9-bb4d-9e4b9c088ab1" + }, + { + "type": "NEW_STUFF", + "data": { + "title": "SNAG YOUR FAVES FROM THE QUEST SHOP - NEW SELECTIONS ARE ON THE WAY!" + }, + "seen": true, + "id": "0decabad-57f8-4cb2-a158-ba7b44da890f" + }, + { + "id": "f6fb06bc-6e63-40e1-b8c8-76bd5889ef51", + "type": "NEW_CHAT_MESSAGE", + "data": { + "group": { + "id": "a8289328-c2ae-4007-9ef4-833b9ac90d37", + "name": "tr4nt0r_2's Party" + } + }, + "seen": false + }, + { + "type": "UNALLOCATED_STATS_POINTS", + "data": { + "points": 21 + }, + "seen": false, + "id": "a88ba092-2d4d-40f9-bf87-902aedf954fe" + } + ], + "userV": 677, + "appVersion": "5.32.5" +} diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json index b4458aa647a..e26dbeb17cc 100644 --- a/tests/components/habitica/fixtures/content.json +++ b/tests/components/habitica/fixtures/content.json @@ -370,7 +370,109 @@ "animalSetAchievements": {}, "stableAchievements": {}, "petSetCompleteAchievs": [], - "quests": {}, + "quests": { + "atom1": { + "text": "Angriff des Banalen, Teil 1: Abwasch-Katastrophe!", + "notes": "Du erreichst die Ufer des Waschbeckensees für eine wohlverdiente Auszeit ... Aber der See ist verschmutzt mit nicht abgespültem Geschirr! Wie ist das passiert? Wie auch immer, Du kannst den See jedenfalls nicht in diesem Zustand lassen. Es gibt nur eine Sache die Du tun kannst: Abspülen und den Ferienort retten! Dazu musst Du aber Seife für den Abwasch finden. Viel Seife ...", + "completion": "Nach gründlichem Schrubben ist das Geschirr sicher am Ufer gestapelt! Du trittst zurück und begutachtest stolz Deiner Hände Arbeit.", + "group": "questGroupAtom", + "prerequisite": { + "lvl": 15 + }, + "value": 4, + "lvl": 15, + "category": "unlockable", + "collect": { + "soapBars": { + "text": "Seifenstücke", + "count": 20 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "atom2", + "text": "Das Monster vom KochLess (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 7, + "exp": 50 + }, + "key": "atom1" + }, + "goldenknight1": { + "text": "Die goldene Ritterin, Teil 1: Ein ernstes Gespräch", + "notes": "Die goldene Ritterin ist Habiticanern mit ihrer Kritik ganz schön auf die Nerven gegangen. Nicht alle Tagesaufgaben erledigt? Eine negative Gewohnheit angeklickt? Sie nimmt dies zum Anlass Dich zu bedrängen, dass Du doch ihrem Beispiel folgen sollst. Sie ist das leuchtende Beispiel eines perfekten Habiticaners und Du bist nichts als ein Versager. Das ist ja mal gar nicht nett! Jeder macht Fehler. Man sollte deshalb nicht mit solcher Kritik drangsaliert werden. Vielleicht solltest Du einige Zeugenaussagen von verletzten Habiticanern zusammentragen und die Goldene Ritterin mal ordentlich zurechtweisen!", + "completion": "Schau Dir nur all diese Zeugenaussagen an! Bestimmt wird das reichen, um die Goldene Ritterin zu überzeugen. Nun musst Du sie nur noch finden.", + "group": "questGroupGoldenknight", + "value": 4, + "lvl": 40, + "category": "unlockable", + "collect": { + "testimony": { + "text": "Zeugenaussagen", + "count": 60 + } + }, + "drop": { + "items": [ + { + "type": "quests", + "key": "goldenknight2", + "text": "Die goldene Ritterin Teil 2: Die goldene Ritterin (Schriftrolle)", + "onlyOwner": true + } + ], + "gp": 15, + "exp": 120 + }, + "key": "goldenknight1" + }, + "dustbunnies": { + "text": "Die ungezähmten Staubmäuse", + "notes": "Es ist schon etwas her, seit Du hier drinnen das letzte Mal Staub gewischt hast, aber Du sorgst dich nicht allzusehr - ein Wenig Staub hat noch nie jemandem geschadet, oder? Erst, als Du Deine Hand in eine der staubigsten Ecken steckst und einen Biss spürst, erinnerst du dich an @InspectorCaracals Warnung: Harmlosen Staub zu lange in Ruhe zu lassen, verwandelt ihn in boshafte Staubmäuse! Du solltest sie besser besiegen, bevor sie ganz Habitica mit feinen Schmutzpartikeln bedecken!", + "group": "questGroupEarnable", + "completion": "Die Staubmäuse verschwinden in einer Rauch-, äh… Staubwolke. Als sich der Staub legt, siehst du dich um. Du hast vergessen, wie hübsch es hier doch aussieht, wenn es sauber ist. Du erkennst einen kleinen Haufen Gold, wo der Staub vorher war. Huch, du hattest dich schon gefragt, wo er abgeblieben war!", + "value": 1, + "category": "unlockable", + "boss": { + "name": "Ungezähmte Staubmäuse", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "dustbunnies" + }, + "basilist": { + "text": "Der Basi-List", + "notes": "Da ist ein Aufruhr auf dem Marktplatz – es sieht ganz so aus, als ob man lieber in die andere Richtung rennen sollte. Da Du aber ein mutiger Abenteurer bist, rennst Du stattdessen darauf zu und entdeckst einen Basi-List, der sich aus einem Haufen unerledigter Aufgaben geformt hat! Alle umstehenden Habiticaner sind aus Angst vor der Länge des Basi-Lists gelähmt und können nicht anfangen zu arbeiten. Von irgendwo in der Nähe hörst Du @Arcosine schreien: \"Schnell! Erledige Deine To-Dos und Tagesaufgaben, um dem Monster die Zähne zu entfernen, bevor sich jemand am Papier schneidet!\" Greife schnell an, Abenteurer, und hake etwas ab - aber Vorsicht! Wenn Du irgendwelche Tagesaufgaben nicht erledigst, wird der Basi-List Dich und Deine Party angreifen!", + "group": "questGroupEarnable", + "completion": "Der Basi-List ist in Papierschnitzel zerfallen, die sanft in Regenbogenfarben schimmern. \"Puh!\" sagt @Arcosine. \"Gut, dass ihr gerade hier wart!\" Du fühlst Dich erfahrener als vorher und sammelst ein paar verstreute Goldstücke zwischen den Papierstücken auf.", + "goldValue": 100, + "category": "unlockable", + "unlockCondition": { + "condition": "party invite", + "text": "Lade Freunde ein" + }, + "boss": { + "name": "Der Basi-List", + "hp": 100, + "str": 0.5, + "def": 1 + }, + "drop": { + "gp": 8, + "exp": 42 + }, + "key": "basilist" + } + }, "questsByLevel": {}, "userCanOwnQuestCategories": [], "itemList": { @@ -450,11 +552,61 @@ "special": {}, "dropEggs": {}, "questEggs": {}, - "eggs": {}, + "eggs": { + "Wolf": { + "text": "Wolfsjunges", + "mountText": "Wolfs-Reittier", + "adjective": "ein treues", + "value": 3, + "key": "Wolf", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein treues Wolfsjunges schlüpfen kann." + }, + "TigerCub": { + "text": "Tigerjunges", + "mountText": "Tiger-Reittier", + "adjective": "ein wildes", + "value": 3, + "key": "TigerCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein wildes Tigerjunges schlüpfen kann." + }, + "PandaCub": { + "text": "Pandajunges", + "mountText": "Panda-Reittier", + "adjective": "ein sanftes", + "value": 3, + "key": "PandaCub", + "notes": "Finde ein Schlüpfelixier, das Du über dieses Ei gießen kannst, damit ein ein sanftes Pandajunges schlüpfen kann." + } + }, "dropHatchingPotions": {}, "premiumHatchingPotions": {}, "wackyHatchingPotions": {}, - "hatchingPotions": {}, + "hatchingPotions": { + "Base": { + "value": 2, + "key": "Base", + "text": "Normales", + "notes": "Gieße dies über ein Ei und es wird ein Normales Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "White": { + "value": 2, + "key": "White", + "text": "Weißes", + "notes": "Gieße dies über ein Ei und es wird ein Weißes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + }, + "Desert": { + "value": 2, + "key": "Desert", + "text": "Wüstenfarbenes", + "notes": "Gieße dies über ein Ei und es wird ein Wüstenfarbenes Haustier daraus schlüpfen.", + "premium": false, + "limited": false + } + }, "pets": {}, "premiumPets": {}, "questPets": {}, @@ -466,7 +618,46 @@ "questMounts": {}, "specialMounts": {}, "mountInfo": {}, - "food": {} + "food": { + "Meat": { + "text": "Fleisch", + "textA": "Fleisch", + "textThe": "das Fleisch", + "target": "Base", + "value": 1, + "key": "Meat", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Milk": { + "text": "Milch", + "textA": "Milch", + "textThe": "die Milch", + "target": "White", + "value": 1, + "key": "Milk", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Potatoe": { + "text": "Kartoffel", + "textA": "eine Kartoffel", + "textThe": "die Kartoffel", + "target": "Desert", + "value": 1, + "key": "Potatoe", + "notes": "Verfüttere dies an ein Haustier und es wächst bald zu einem kräftigen Reittier heran.", + "canDrop": true + }, + "Saddle": { + "sellWarningNote": "Hey! Das ist ein sehr nützlicher Gegenstand! Weißt Du, wie Du den Sattel mit Deinen Haustieren nutzt?", + "text": "Magischer Sattel", + "value": 5, + "notes": "Lässt eines Deiner Haustiere augenblicklich zum Reittier heranwachsen.", + "canDrop": false, + "key": "Saddle" + } + } }, "appVersion": "5.29.2" } diff --git a/tests/components/habitica/fixtures/create_tag.json b/tests/components/habitica/fixtures/create_tag.json new file mode 100644 index 00000000000..638ec69d84e --- /dev/null +++ b/tests/components/habitica/fixtures/create_tag.json @@ -0,0 +1,8 @@ +{ + "success": true, + "data": { + "name": "Home Assistant", + "id": "8bc0afbf-ab8e-49a4-982d-67a40557ed1a" + }, + "notifications": [] +} diff --git a/tests/components/habitica/fixtures/duedate_fixture_9.json b/tests/components/habitica/fixtures/duedate_fixture_9.json new file mode 100644 index 00000000000..f908ad0deae --- /dev/null +++ b/tests/components/habitica/fixtures/duedate_fixture_9.json @@ -0,0 +1,51 @@ +{ + "success": true, + "data": [ + { + "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "frequency": "weekly", + "everyX": 1, + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "streak": 1, + "nextDue": ["2024-09-20T22:00:00.000Z", "2024-09-27T22:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Zahnseide benutzen", + "notes": "Klicke um Änderungen zu machen!", + "tags": [], + "value": -2.9663035443712333, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "startDate": "2024-09-25T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-07-07T17:51:53.268Z", + "updatedAt": "2024-09-21T22:24:20.154Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa" + } + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/reorder_dailies_response.json b/tests/components/habitica/fixtures/reorder_dailies_response.json new file mode 100644 index 00000000000..3ad38ae9c2f --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_dailies_response.json @@ -0,0 +1,15 @@ +{ + "success": true, + "data": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/reorder_todos_response.json b/tests/components/habitica/fixtures/reorder_todos_response.json new file mode 100644 index 00000000000..ba8118aa1da --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_todos_response.json @@ -0,0 +1,12 @@ +{ + "success": true, + "data": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/reward.json b/tests/components/habitica/fixtures/reward.json new file mode 100644 index 00000000000..1c639c4298e --- /dev/null +++ b/tests/components/habitica/fixtures/reward.json @@ -0,0 +1,27 @@ +{ + "success": true, + "data": { + "_id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + "type": "reward", + "text": "Belohne Dich selbst", + "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", + "tags": [], + "value": 10, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "reminders": [], + "createdAt": "2024-07-07T17:51:53.266Z", + "updatedAt": "2024-07-07T17:51:53.266Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + }, + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 3bb646be512..378652138bc 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -533,7 +533,10 @@ "type": "reward", "text": "Belohne Dich selbst", "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", - "tags": [], + "tags": [ + "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb" + ], "value": 10, "priority": 1, "attribute": "str", @@ -598,6 +601,56 @@ "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", "isDue": false, "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": false + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "7d92278b-9361-4854-83b6-0a66b57dce20", + "frequency": "weekly", + "everyX": 1, + "streak": 1, + "nextDue": [ + "2024-12-14T23:00:00.000Z", + "2025-01-18T23:00:00.000Z", + "2025-02-15T23:00:00.000Z", + "2025-03-15T23:00:00.000Z", + "2025-04-19T23:00:00.000Z", + "2025-05-17T23:00:00.000Z" + ], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Lerne eine neue Programmiersprache", + "notes": "Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-09-20T23:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [], + "checklist": [], + "reminders": [], + "createdAt": "2024-10-10T15:57:14.304Z", + "updatedAt": "2024-11-27T23:47:29.986Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "7d92278b-9361-4854-83b6-0a66b57dce20" } ], "notifications": [ diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index d97ad458c77..58eca2837b6 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -2,8 +2,18 @@ "success": true, "data": { "api_user": "test-api-user", - "profile": { "name": "test-user" }, - "auth": { "local": { "username": "test-username" } }, + "profile": { + "name": "test-user", + "blurb": "My mind is a swirling miasma of scintillating thoughts and turgid ideas.", + "imageUrl": "https://pbs.twimg.com/profile_images/378800000771780608/a32e71fe6a64eba6773c20d289eddc8e.png" + }, + "auth": { + "local": { "username": "test-username" }, + "timestamps": { + "created": "2013-12-02T22:23:29.249Z", + "loggedin": "2025-02-02T03:14:33.864Z" + } + }, "stats": { "buffs": { "str": 26, @@ -112,8 +122,57 @@ "eyewear": "eyewear_armoire_plagueDoctorMask", "body": "body_special_aetherAmulet" } + }, + "quests": { + "atom1": 1, + "goldenknight1": 0, + "dustbunnies": 1, + "basilist": 0 + }, + "food": { + "Saddle": 2, + "Meat": 0, + "Milk": 1, + "Potatoe": 2 + }, + "hatchingPotions": { + "Base": 2, + "White": 0, + "Desert": 1 + }, + "eggs": { + "Wolf": 1, + "TigerCub": 0, + "PandaCub": 2 } }, - "balance": 10 + "balance": 10, + "purchased": { + "plan": { + "consecutive": { + "trinkets": 0 + } + } + }, + "webhooks": [ + { + "id": "43a67e37-1bae-4b11-8d3d-6c4b1b480231", + "type": "taskActivity", + "label": "My Webhook", + "url": "https://some-webhook-url.com", + "enabled": true, + "failures": 0, + "options": { + "created": false, + "updated": false, + "deleted": false, + "checklistScored": false, + "scored": true + }, + "createdAt": "2025-02-08T22:06:08.894Z", + "updatedAt": "2025-02-08T22:06:17.195Z" + } + ], + "loginIncentives": 241 } } diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index 0a4076a6135..ffe4ce83d0e 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_button.ambr b/tests/components/habitica/snapshots/test_button.ambr index 76a0198d5b2..5c6ad640039 100644 --- a/tests/components/habitica/snapshots/test_button.ambr +++ b/tests/components/habitica/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +387,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -425,6 +434,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,6 +482,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +529,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -564,6 +576,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -611,6 +624,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +672,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -751,6 +767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -845,6 +863,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -891,6 +910,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -937,6 +957,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -984,6 +1005,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1030,6 +1052,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1077,6 +1100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1124,6 +1148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1171,6 +1196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1264,6 +1291,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 8be45ccc0fd..2948f31f1cf 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -906,6 +906,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -959,6 +960,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1011,6 +1013,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1063,6 +1066,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index b4304e33ec8..718aea99ebc 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -8,49 +8,31 @@ 'habitica_data': dict({ 'tasks': list([ dict({ - 'Type': 'habit', - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, - 'completed': None, 'counterDown': 0, 'counterUp': 0, 'createdAt': '2024-10-10T15:57:14.287000+00:00', - 'date': None, 'daysOfMonth': list([ ]), 'down': False, - 'everyX': None, 'frequency': 'daily', 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': '30923acd-3b4c-486d-9ef3-c8f57cf56049', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -66,62 +48,40 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', + 'type': 'habit', 'up': True, 'updatedAt': '2024-10-10T15:57:14.287000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': 0.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'Type': 'todo', - 'alias': None, 'attribute': 'str', 'byHabitica': True, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.290000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, - 'everyX': None, - 'frequency': None, 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -137,62 +97,38 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', - 'up': None, + 'type': 'todo', 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': -6.418582324043852, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'Type': 'reward', - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, - 'completed': None, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.290000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, - 'everyX': None, - 'frequency': None, 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), - 'history': None, + 'history': list([ + ]), 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -208,106 +144,73 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', - 'up': None, + 'type': 'reward', 'updatedAt': '2024-10-10T15:57:14.290000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': 10.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'Type': 'daily', - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.304000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, 'everyX': 1, 'frequency': 'weekly', 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ dict({ 'completed': True, 'date': '2024-10-30T19:37:01.817000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.0, }), dict({ 'completed': True, 'date': '2024-10-31T23:33:14.890000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.9747, }), dict({ 'completed': False, 'date': '2024-11-05T18:25:04.730000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.024043774264157, }), dict({ 'completed': False, 'date': '2024-11-21T15:09:07.573000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 0.049944135963563174, }), dict({ 'completed': False, 'date': '2024-11-22T00:41:21.228000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': -0.9487768368544092, }), dict({ 'completed': False, 'date': '2024-11-27T19:34:28.973000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': -1.973387732005249, }), ]), @@ -339,7 +242,7 @@ 'tags': list([ ]), 'text': 'task text', - 'up': None, + 'type': 'daily', 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': -1.973387732005249, @@ -350,60 +253,23 @@ ]), 'user': dict({ 'achievements': dict({ - 'backToBasics': None, - 'boneCollector': None, 'challenges': list([ ]), 'completedTask': True, 'createdTask': True, - 'dustDevil': None, - 'fedPet': None, - 'goodAsGold': None, - 'hatchedPet': None, - 'joinedChallenge': None, - 'joinedGuild': None, - 'partyUp': None, 'perfect': 2, - 'primedForPainting': None, - 'purchasedEquipment': None, 'quests': dict({ - 'atom1': None, - 'atom2': None, - 'atom3': None, - 'bewilder': None, - 'burnout': None, - 'dilatory': None, - 'dilatory_derby': None, - 'dysheartener': None, - 'evilsanta': None, - 'evilsanta2': None, - 'gryphon': None, - 'harpy': None, - 'stressbeast': None, - 'vice1': None, - 'vice3': None, }), - 'seeingRed': None, - 'shadyCustomer': None, 'streak': 0, - 'tickledPink': None, 'ultimateGearSets': dict({ 'healer': False, 'rogue': False, 'warrior': False, 'wizard': False, }), - 'violetsAreBlue': None, }), 'auth': dict({ - 'apple': None, - 'facebook': None, - 'google': None, 'local': dict({ - 'email': None, - 'has_password': None, - 'lowerCaseUsername': None, - 'username': None, }), 'timestamps': dict({ 'created': '2024-10-10T15:57:01.106000+00:00', @@ -412,17 +278,11 @@ }), }), 'backer': dict({ - 'npc': None, - 'tier': None, - 'tokensApplied': None, }), 'balance': 0.0, 'challenges': list([ ]), 'contributor': dict({ - 'contributions': None, - 'level': None, - 'text': None, }), 'extra': dict({ }), @@ -431,23 +291,17 @@ 'armoireEnabled': True, 'armoireOpened': False, 'cardReceived': False, - 'chatRevoked': None, - 'chatShadowMuted': None, 'classSelected': False, 'communityGuidelinesAccepted': True, 'cronCount': 6, 'customizationsNotification': True, 'dropsEnabled': False, 'itemsEnabled': True, - 'lastFreeRebirth': None, 'lastNewStuffRead': '', 'lastWeeklyRecap': '2024-10-10T15:57:01.106000+00:00', - 'lastWeeklyRecapDiscriminator': None, 'levelDrops': dict({ }), - 'mathUpdates': None, 'newStuff': False, - 'onboardingEmailsPhase': None, 'rebirthEnabled': False, 'recaptureEmailsPhase': 0, 'rewrite': True, @@ -506,101 +360,53 @@ 'history': dict({ 'exp': list([ dict({ - 'completed': None, 'date': '2024-10-30T19:37:01.970000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 24.0, }), dict({ - 'completed': None, 'date': '2024-10-31T23:33:14.972000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 48.0, }), dict({ - 'completed': None, 'date': '2024-11-05T18:25:04.681000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-21T15:09:07.501000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-22T00:41:21.137000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-27T19:34:28.887000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), ]), 'todos': list([ dict({ - 'completed': None, 'date': '2024-10-30T19:37:01.970000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -5.0, }), dict({ - 'completed': None, 'date': '2024-10-31T23:33:14.972000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -10.129783523135325, }), dict({ - 'completed': None, 'date': '2024-11-05T18:25:04.681000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -16.396221153338182, }), dict({ - 'completed': None, 'date': '2024-11-21T15:09:07.501000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -22.8326979965846, }), dict({ - 'completed': None, 'date': '2024-11-22T00:41:21.137000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -29.448636229365235, }), dict({ - 'completed': None, 'date': '2024-11-27T19:34:28.887000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -36.25425987861077, }), ]), @@ -641,23 +447,13 @@ 'gear': dict({ 'costume': dict({ 'armor': 'armor_base_0', - 'back': None, - 'body': None, - 'eyewear': None, 'head': 'head_base_0', - 'headAccessory': None, 'shield': 'shield_base_0', - 'weapon': None, }), 'equipped': dict({ 'armor': 'armor_base_0', - 'back': None, - 'body': None, - 'eyewear': None, 'head': 'head_base_0', - 'headAccessory': None, 'shield': 'shield_base_0', - 'weapon': None, }), 'owned': dict({ 'armor_special_bardRobes': True, @@ -734,7 +530,6 @@ }), 'lastCron': '2024-11-27T19:34:28.887000+00:00', 'loginIncentives': 6, - 'needsCron': None, 'newMessages': dict({ }), 'notifications': list([ @@ -745,7 +540,6 @@ 'orderAscending': 'ascending', 'quest': dict({ 'RSVPNeeded': True, - 'completed': None, 'key': 'dustbunnies', 'progress': dict({ 'collect': dict({ @@ -757,37 +551,31 @@ }), }), 'permissions': dict({ - 'challengeAdmin': None, - 'coupons': None, - 'fullAccess': None, - 'moderator': None, - 'news': None, - 'userSupport': None, }), 'pinnedItems': list([ dict({ - 'Type': 'marketGear', 'path': 'gear.flat.weapon_warrior_0', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.armor_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.shield_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.head_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'potion', 'path': 'potion', + 'type': 'potion', }), dict({ - 'Type': 'armoire', 'path': 'armoire', + 'type': 'armoire', }), ]), 'pinnedItemsOrder': list([ @@ -796,7 +584,6 @@ 'advancedCollapsed': False, 'allocationMode': 'flat', 'autoEquip': True, - 'automaticAllocation': None, 'background': 'violet', 'chair': 'none', 'costume': False, @@ -886,9 +673,6 @@ }), }), 'profile': dict({ - 'blurb': None, - 'imageUrl': None, - 'name': None, }), 'purchased': dict({ 'ads': False, @@ -902,21 +686,11 @@ }), 'hair': dict({ }), - 'mobileChat': None, 'plan': dict({ 'consecutive': dict({ - 'count': None, - 'gemCapExtra': None, - 'offset': None, - 'trinkets': None, }), - 'dateUpdated': None, - 'extraMonths': None, - 'gemsBought': None, 'mysteryItems': list([ ]), - 'perkMonthCount': None, - 'quantity': None, }), 'shirt': dict({ }), @@ -926,81 +700,73 @@ }), 'pushDevices': list([ ]), - 'secret': None, 'stats': dict({ - 'Class': 'warrior', - 'Int': 0, - 'Str': 0, 'buffs': dict({ - 'Int': 0, - 'Str': 0, 'con': 0, + 'int': 0, 'per': 0, 'seafoam': False, 'shinySeed': False, 'snowball': False, 'spookySparkles': False, 'stealth': 0, + 'str': 0, 'streaks': False, }), + 'class': 'warrior', 'con': 0, 'exp': 41, 'gp': 11.100978952781748, 'hp': 25.40000000000002, + 'int': 0, 'lvl': 2, 'maxHealth': 50, 'maxMP': 32, 'mp': 32.0, 'per': 0, 'points': 2, + 'str': 0, 'toNextLevel': 50, 'training': dict({ - 'Int': 0, - 'Str': 0.0, 'con': 0, + 'int': 0, 'per': 0, + 'str': 0.0, }), }), 'tags': list([ dict({ 'challenge': True, - 'group': None, 'id': 'c1a35186-9895-4ac0-9cd7-49e7bb875695', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '53d1deb8-ed2b-4f94-bbfc-955e9e92aa98', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '29bf6a99-536f-446b-838f-a81d41e1ed4d', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '1b1297e7-4fd8-460a-b148-e92d7bcfa9a5', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '05e6cf40-48ea-415a-9b8b-e2ecad258ef6', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': 'fe53f179-59d8-4c28-9bf7-b9068ab552a4', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': 'c44e9e8c-4bff-42df-98d5-1a1a7b69eada', 'name': 'tag', }), diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 7464a5fd36d..1fbc9eca595 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -43,6 +44,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItMTIgLTEyIDcyIDcyIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZmlsbD0iIzFGNkVBMiIgZD0iTTI0LjQ2MiAxLjE1MWMtMy42OTUgMy43MTQtNy4zMjEgMTQuNjItOS4yMzYgMjAuOTEzLS44NC0xLjA2Mi0xLjc5OS0yLjA3Ni0zLjE1OC0zLjMyNi00LjgwNiAzLjUxNC03LjM1NyA3LjI1LTEwLjY0NiAxMS44MTYgOC45MTYgNy45MjYgMTEuMjMzIDkuMjU2IDIyLjk5NSAxNi4wMDVsLjA0NS0uMDI1aC4wMDFsLjA0NS4wMjVjMTEuNzYtNi43NDkgMTQuMDc5LTguMDggMjIuOTk0LTE2LjAwNS0zLjI4Ny00LjU2Ni01Ljg0MS04LjMwMi0xMC42NDUtMTEuODE2LTEuMzYxIDEuMjUtMi4zMTcgMi4yNjQtMy4xNTggMy4zMjYtMS45MTUtNi4yOTQtNS41NDMtMTcuMi05LjIzNi0yMC45MTNoLS4wMDF6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzI3OEFCRiIgZD0iTTI0LjQ2NCAzMi4xMDhsMy41NjEtMS4yNjVjNi4wNzUtLjYwNSA4LjQyNS0yLjI4MSA5LjQyNi0zLjQ5OGw2LjA1MyAyLjcwNy4wMDIuMDEzYS40NS40NSAwIDAgMS0uMTA1LjI4NiA0Ljk3IDQuOTcgMCAwIDEtLjMyLjMzYy0zLjQwNSAzLjMxNS0xOC4yMzcgMTEuOTgxLTE4LjYxNyAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM1M0I0RTUiIGQ9Ik0zNi40MDYgMjMuMDQxYy4yMy0uMTg4LjI5Mi0uMzQuNjc0LS4zMzYgMS4xODMuMDY3IDUuMTY5IDUuMTc1IDYuMDY0IDYuNDYxLjE5NC4yNzguMzY2LjYxLjM2Ljg4NWwtNi4wNTMtMi43MDdjLjIzNi0uMzY3LjE5LS45Mi0uMzU1LTEuMjUyLS44MTgtLjUwMS0yLjUyOS45NDktMi45NTkuMjg0LS41OTktLjkyNyAyLjA0LTMuMTQ3IDIuMjctMy4zMzUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMkFBMENGIiBkPSJNMjMuNTkxIDcuNDg3Yy01LjM5IDE1LjQxOS04LjIyNyAyMy44MTctOC4yMjcgMjMuODE3bDkuMDk3IDQuNzZWNi44MzljLS4zNiAwLS43MTguMjE2LS44Ny42NDgiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMjc4QUJGIiBkPSJNMjUuMzMyIDcuNDg3Yy0uMTUyLS40MzItLjUxLS42NDgtLjg3LS42NDh2MjkuMjI1bDkuMDk3LTQuNzZzLTIuODM3LTguMzk4LTguMjI3LTIzLjgxNyIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM0REIyRDYiIGQ9Ik0yNC40NjIgMzIuMTA4TDIwLjkgMzAuODQzYy02LjA3NS0uNjA1LTguMzQyLTIuMzUyLTkuNDI1LTMuNDk4TDUuNDIgMzAuMDUybC0uMDAyLjAxM2EuNDUuNDUgMCAwIDAgLjEwNi4yODZjLjA5LjEwNC4xOTcuMjE1LjMxOC4zMyAzLjQwNiAzLjMxNSAxOC4yMzggMTEuOTgxIDE4LjYxOSAxMi4yMDJWMzIuMTA4eiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiM2QkM0RTkiIGQ9Ik0xMi41MTkgMjMuMDQxYy0uMjMtLjE4OC0uMjkyLS4zNC0uNjc0LS4zMzYtMS4xODMuMDY3LTUuMTY5IDUuMTc1LTYuMDYzIDYuNDYxLS4xOTQuMjc4LS4zNjcuNjEtLjM2MS44ODVsNi4wNTMtMi43MDdjLS4yMzYtLjM2Ny0uMTktLjkyLjM1Ni0xLjI1Mi44MTctLjUwMSAyLjUyOC45NDkgMi45NTguMjg0LjYtLjkyNy0yLjAzOS0zLjE0Ny0yLjI3LTMuMzM1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0E5REJGNiIgZD0iTTI0LjQ2MyAzNC45OTh2LTUuMTAxbC03LjIxLTQuMTMtMS40OCA0LjA0OXoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjODRDRkYyIiBkPSJNMjQuNDYzIDM0Ljk5OHYtNS4xMDFsNy4yMDctNC4xMyAxLjQ4MyA0LjA0OXoiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Class', 'options': list([ 'warrior', @@ -66,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +124,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,7 +154,12 @@ # name: test_sensors[sensor.test_user_display_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'blurb': 'My mind is a swirling miasma of scintillating thoughts and turgid ideas.', + 'entity_picture': 'https://pbs.twimg.com/profile_images/378800000771780608/a32e71fe6a64eba6773c20d289eddc8e.png', 'friendly_name': 'test-user Display name', + 'joined': datetime.date(2013, 12, 2), + 'last_login': datetime.date(2025, 2, 1), + 'total_logins': 241, }), 'context': , 'entity_id': 'sensor.test_user_display_name', @@ -160,6 +169,58 @@ 'state': 'test-user', }) # --- +# name: test_sensors[sensor.test_user_eggs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_eggs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eggs', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_eggs_total', + 'unit_of_measurement': 'eggs', + }) +# --- +# name: test_sensors[sensor.test_user_eggs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Pandajunges': 2, + 'Tigerjunges': 0, + 'Wolfsjunges': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Egg_Egg.png', + 'friendly_name': 'test-user Eggs', + 'unit_of_measurement': 'eggs', + }), + 'context': , + 'entity_id': 'sensor.test_user_eggs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_experience-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -167,6 +228,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -196,6 +258,7 @@ # name: test_sensors[sensor.test_user_experience-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Experience', 'unit_of_measurement': 'XP', }), @@ -214,6 +277,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -265,6 +329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +362,7 @@ # name: test_sensors[sensor.test_user_gold-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEyIiBmaWxsPSIjRkZBNjIzIi8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTYuMyAxNy43Yy0zLjEtMy4xLTMuMS04LjIgMC0xMS4zIDMuMS0zLjEgOC4yLTMuMSAxMS4zIDAiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE3LjcgNi4zYzMuMSAzLjEgMy4xIDguMiAwIDExLjMtMy4xIDMuMS04LjIgMy4xLTExLjMgMCIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0JGN0QxQSIgZD0iTTEyIDJDNi41IDIgMiA2LjUgMiAxMnM0LjUgMTAgMTAgMTAgMTAtNC41IDEwLTEwUzE3LjUgMiAxMiAyem0wIDE4Yy00LjQgMC04LTMuNi04LThzMy42LTggOC04IDggMy42IDggOC0zLjYgOC04IDh6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCRjdEMUEiIGQ9Ik0xMyA5djJoLTJWOUg5djZoMnYtMmgydjJoMlY5eiIgb3BhY2l0eT0iLjc1Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Gold', 'unit_of_measurement': 'GP', }), @@ -315,6 +381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -514,6 +581,58 @@ 'state': '4', }) # --- +# name: test_sensors[sensor.test_user_hatching_potions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_hatching_potions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hatching potions', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_hatching_potions_total', + 'unit_of_measurement': 'potions', + }) +# --- +# name: test_sensors[sensor.test_user_hatching_potions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Normales': 2, + 'Weißes': 0, + 'Wüstenfarbenes': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_RoyalPurple.png', + 'friendly_name': 'test-user Hatching potions', + 'unit_of_measurement': 'potions', + }), + 'context': , + 'entity_id': 'sensor.test_user_hatching_potions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_sensors[sensor.test_user_health-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -521,6 +640,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -553,6 +673,7 @@ # name: test_sensors[sensor.test_user_health-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGNzRFNTIiIGQ9Ik0yIDQuNUw2LjE2NyAyIDEyIDUuMTY3IDE3LjgzMyAyIDIyIDQuNVYxMmwtNC4xNjcgNS44MzNMMTIgMjJsLTUuODMzLTQuMTY3TDIgMTJ6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGNjE2NSIgZD0iTTcuMzMzIDE2LjY2N0wzLjY2NyAxMS41VjUuNDE3bDIuNS0xLjVMMTIgNy4wODNsNS44MzMtMy4xNjYgMi41IDEuNVYxMS41bC0zLjY2NiA1LjE2N0wxMiAxOS45MTd6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w0LjY2NyAyLjU4NEwxMiAxOS45MTd6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsLTQuNjY3IDIuNTg0TDEyIDE5LjkxN3oiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjMzMyAxNi42NjdMMy42NjcgMTEuNSAxMiAxNC4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQjUyNDI4IiBkPSJNMTYuNjY3IDE2LjY2N2wzLjY2Ni01LjE2N0wxMiAxNC4wODN6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNsNS44MzMtMTAuMTY2IDIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii4zNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNCNTI0MjgiIGQ9Ik0xMiAxNC4wODNMNi4xNjcgMy45MTdsLTIuNSAxLjVWMTEuNXoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M0w2LjE2NyAzLjkxNyAxMiA3LjA4M3oiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDE0LjA4M2w1LjgzMy0xMC4xNjZMMTIgNy4wODN6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNOS4xNjcgMTQuODMzbC0zLTQuMTY2VjYuODMzaC4wODNMMTIgOS45MTdsNS43NS0zLjA4NGguMDgzdjMuODM0bC0zIDQuMTY2TDEyIDE2LjkxN3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Health', 'unit_of_measurement': 'HP', }), @@ -571,6 +692,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -626,6 +748,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +795,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +828,7 @@ # name: test_sensors[sensor.test_user_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Mana', 'unit_of_measurement': 'MP', }), @@ -722,6 +847,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -769,6 +895,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +925,7 @@ # name: test_sensors[sensor.test_user_max_mana-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiMyOTk1Q0QiIGQ9Ik0yMiAxNWwtMTAgOS0xMC05TDEyIDB6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iIzUwQjVFOSIgZD0iTTQuNiAxNC43bDcuNC0zdjkuNnoiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjMUY3MDlBIiBkPSJNMTIgMTEuN2w3LjQgMy03LjQgNi42eiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEyIDExLjdWMy42bDcuNCAxMS4xeiIgb3BhY2l0eT0iLjI1Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNiAxNC43TDEyIDMuNnY4LjF6IiBvcGFjaXR5PSIuNSIvPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik03LjIgMTQuM0wxMiA3LjJsNC44IDcuMS00LjggNC4zeiIgb3BhY2l0eT0iLjUiLz4KICAgIDwvZz4KPC9zdmc+', 'friendly_name': 'test-user Max. mana', 'unit_of_measurement': 'MP', }), @@ -816,6 +944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,6 +996,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -896,6 +1026,7 @@ # name: test_sensors[sensor.test_user_next_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciICB2aWV3Qm94PSItNiAtNiAzNiAzNiI+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxwYXRoIGZpbGw9IiNGRkE2MjMiIGQ9Ik0xNiAxNmw4LTQtOC00LTQtOC00IDgtOCA0IDggNCA0IDh6Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1LTIuNUwxMiAxMnpNMTIgMTkuNWwtMi41LTVMMTIgMTJ6TTE5LjUgMTJsLTUgMi41TDEyIDEyek0xMiA0LjVsMi41IDVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTkuNSAxMmwtNS0yLjVMMTIgMTJ6IiBvcGFjaXR5PSIuMjUiLz4KICAgICAgICA8cGF0aCBmaWxsPSIjQkY3RDFBIiBkPSJNMTIgMTkuNWwyLjUtNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTQuNSAxMmw1IDIuNUwxMiAxMnpNMTIgNC41bC0yLjUgNUwxMiAxMnoiIG9wYWNpdHk9Ii41Ii8+CiAgICAgICAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTEwLjggMTMuMkw4LjUgMTJsMi4zLTEuMkwxMiA4LjVsMS4yIDIuMyAyLjMgMS4yLTIuMyAxLjItMS4yIDIuM3oiIG9wYWNpdHk9Ii41Ii8+CiAgICA8L2c+Cjwvc3ZnPg==', 'friendly_name': 'test-user Next level', 'unit_of_measurement': 'XP', }), @@ -914,6 +1045,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -962,6 +1094,111 @@ 'state': '75', }) # --- +# name: test_sensors[sensor.test_user_pet_food-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_pet_food', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pet food', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_food_total', + 'unit_of_measurement': 'foods', + }) +# --- +# name: test_sensors[sensor.test_user_pet_food-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Fleisch': 0, + 'Kartoffel': 2, + 'Milch': 1, + 'entity_picture': 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNTgiIHZpZXdCb3g9IjAgMCA2MCA1OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CjxyZWN0IHg9IjM4IiB5PSI2IiB3aWR0aD0iMjIiIGhlaWdodD0iMjYiIGZpbGw9InVybCgjcGF0dGVybjBfMTMyN18yNTYpIj48L3JlY3Q+CjxyZWN0IHdpZHRoPSIyNiIgaGVpZ2h0PSIyNiIgZmlsbD0idXJsKCNwYXR0ZXJuMV8xMzI3XzI1NikiPjwvcmVjdD4KPHJlY3QgeD0iOCIgeT0iMzYiIHdpZHRoPSIzMCIgaGVpZ2h0PSIyMiIgZmlsbD0idXJsKCNwYXR0ZXJuMl8xMzI3XzI1NikiPjwvcmVjdD4KPGRlZnM+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMF8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTBfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wNDU0NTQ1IDAuMDM4NDYxNSkiPjwvdXNlPgo8L3BhdHRlcm4+CjxwYXR0ZXJuIGlkPSJwYXR0ZXJuMV8xMzI3XzI1NiIgcGF0dGVybkNvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giIHdpZHRoPSIxIiBoZWlnaHQ9IjEiPgo8dXNlIHhsaW5rOmhyZWY9IiNpbWFnZTFfMTMyN18yNTYiIHRyYW5zZm9ybT0ic2NhbGUoMC4wMzg0NjE1KSI+PC91c2U+CjwvcGF0dGVybj4KPHBhdHRlcm4gaWQ9InBhdHRlcm4yXzEzMjdfMjU2IiBwYXR0ZXJuQ29udGVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgd2lkdGg9IjEiIGhlaWdodD0iMSI+Cjx1c2UgeGxpbms6aHJlZj0iI2ltYWdlMl8xMzI3XzI1NiIgdHJhbnNmb3JtPSJzY2FsZSgwLjAzMzMzMzMgMC4wNDU0NTQ1KSI+PC91c2U+CjwvcGF0dGVybj4KPGltYWdlIGlkPSJpbWFnZTBfMTMyN18yNTYiIHdpZHRoPSIyMiIgaGVpZ2h0PSIyNiIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFCWUFBQUFhQ0FZQUFBQ3pkcXhBQUFBQUFYTlNSMElBcnM0YzZRQUFBUWxKUkVGVVNBMjkwazFxd2xBVXhmRnNwK3NvWFlkem9lQWFXcUZiY1N4MTRMVFFTYnVBanJ1TFZnZFBudkFyY3NJalJsT0Z5OG45K3Q5alNOY04vQjd1N3N0cERJeWYzejZGMXVmek4yTXlRZnZmOTFMalo3czZSdlpqdlozbTR0Vmd3TjM2cTlUZ2tLclQ3K2VQVXNOZTA2b0JpNEJVblE2Q0UyaUJBcVhxVTV3LzV3b1dEVkwxVkgySzB3TWJBSGliZlpZYTZpMDFkenN3Nnk3bTUrVWZjTXhoUzNGd080WEp3SUNjNWVmRmFlcWc0NXVCMDVtODVWQ2R3ZDY3OVNxQVVnRmEyZ1FEV1RRb1Q4Mit2T2Q0Y3JBTEx0S3N6eDhYcFVhcmI3Nm5GcWdCK2NWZ29GVGdwK1ZMcVNIUHVkRTUwTCtCMTYrYlVzT2gwUTV6QVdoeXNFTU95Q2ZUc2VBRFNTQzNudTVpRmFjQUFBQUFTVVZPUks1Q1lJST0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UxXzEzMjdfMjU2IiB3aWR0aD0iMjYiIGhlaWdodD0iMjYiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQm9BQUFBYUNBWUFBQUNwU2t6T0FBQUFBWE5TUjBJQXJzNGM2UUFBQVNsSlJFRlVTQTNGMURGT3cwQVFCVkRUUmhTcDZSSFVhVkxRY0FXT0VDa05SNkhpUHRSY0psMUtxQlp0cEVmeGpiV3J4QTZXUmw4em52bC8vcTdsWVlqbmJuTmIvZ3B0K2U3NWZWMXFxT3Ryb29GRWcxay9XeWdKNzNjM3BRWUIrY1VDL3k2VVRqaDYyMjVMamQwd25NS2kzWmhITlpzUVlvaFl2djk0TERYVU9UbSt2cFFhWHc5UHAxQ2ZkSVFRSXBSZkxJUVFPbnZFTU92Nk9XazZNd0NUY0hZaFJ3UlR3QUtKN3VUNzgxQnFjS2orZTFlY0VJQ3pDeUdtdkZtdFNnMmJFN1JwYnE3UCs4bTd1cG9RSjVBalo1d2IyeHltZzNROCtjZTR1cENOYzhPV0EzT3c2VWpqNGtMdWlDQnNDYWZqcHFQRmhhYSt2bDVoanZYN3VQQ09VSU1CNkFnaDRzejE0eGtKWkVFamRPYUlXbWd1ZVVlNVJyaVlVQ29UN01XYzc4NTdCZlFoL2dGQ2FOUWFobDJ1M2dBQUFBQkpSVTVFcmtKZ2dnPT0iPjwvaW1hZ2U+CjxpbWFnZSBpZD0iaW1hZ2UyXzEzMjdfMjU2IiB3aWR0aD0iMzAiIGhlaWdodD0iMjIiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBQjRBQUFBV0NBWUFBQURYWXl6UEFBQUFBWE5TUjBJQXJzNGM2UUFBQVJSSlJFRlVTQTNGMDhGdEFqRVVCRkNhZ0FZU0tkM1FBc1VnY1VrRHFTTzMxSkJqTHJuU0JsSms1TU9EMVdndDdSbzVJSDJOL2NlZStiUHNiamFEZjY4dmIyVmFnKzN1OGxQVHVyNHpLMWNwWko4eStoK0g3MUxMUHM4dDNoTklUQUY4dHpFQitQbHpLZFA2UGYrVld2akUwLzZyMUZvOVFBcE5UZXQ2dURIRDQvdTV6Qlhlb0ptME96SGhPZFBhd3o5c1RJQmdHbnJFeWV2dnR0dFNpMDVpdm95M3ZZTXBiQUFHeWVzL2JFeklmOFlJdGdZeCtDM0owb1dMdzR3WkpIcFVra2tzb1g0aW5sNHpxQU9KLzJic080TVNHa2d5aWZCUTMvbG1Vb1NERENGQi9IRGpOR1FNRFFDOWZIZ29XQk1kYkNYRlE0YXcyOWhFaEExZ2o0ZjZpZmpWU09ocHhnWllQWG52Qllhd1YyZnB2U3R4R0Z5dUlYV3NXUUFBQUFCSlJVNUVya0pnZ2c9PSI+PC9pbWFnZT4KPC9kZWZzPgo8L3N2Zz4=', + 'friendly_name': 'test-user Pet food', + 'unit_of_measurement': 'foods', + }), + 'context': , + 'entity_id': 'sensor.test_user_pet_food', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_quest_scrolls', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quest scrolls', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_quest_scrolls', + 'unit_of_measurement': 'scrolls', + }) +# --- +# name: test_sensors[sensor.test_user_quest_scrolls-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Angriff des Banalen, Teil 1: Abwasch-Katastrophe!': 1, + 'Der Basi-List': 0, + 'Die goldene Ritterin, Teil 1: Ein ernstes Gespräch': 0, + 'Die ungezähmten Staubmäuse': 1, + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Quest scrolls', + 'unit_of_measurement': 'scrolls', + }), + 'context': , + 'entity_id': 'sensor.test_user_quest_scrolls', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -969,6 +1206,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1033,6 +1271,10 @@ 'th': False, 'w': True, }), + 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', + ]), 'text': 'Belohne Dich selbst', 'type': 'reward', 'value': 10.0, @@ -1048,6 +1290,55 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_saddles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_saddles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Saddles', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_saddle', + 'unit_of_measurement': 'saddles', + }) +# --- +# name: test_sensors[sensor.test_user_saddles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_Food_Saddle.png', + 'friendly_name': 'test-user Saddles', + 'unit_of_measurement': 'saddles', + }), + 'context': , + 'entity_id': 'sensor.test_user_saddles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[sensor.test_user_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1055,6 +1346,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 3030b228d38..79c9e3eab66 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -3,9 +3,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -20,13 +19,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -44,12 +43,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -66,18 +65,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -92,13 +91,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -117,19 +116,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -146,18 +145,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -172,13 +171,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -196,12 +195,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -218,18 +217,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -244,13 +243,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -269,19 +268,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -298,18 +297,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -321,7 +320,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -329,13 +328,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -354,7 +353,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -362,7 +361,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -370,7 +369,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -378,7 +377,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -386,7 +385,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -394,7 +393,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -402,7 +401,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -410,7 +409,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -418,7 +417,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -426,7 +425,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -434,25 +433,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -464,23 +463,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -495,13 +494,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -520,7 +519,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -528,7 +527,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -536,7 +535,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -544,7 +543,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -552,7 +551,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -560,7 +559,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -568,7 +567,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -576,7 +575,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -584,7 +583,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -592,30 +591,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -627,23 +626,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -655,7 +654,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -663,13 +662,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -687,18 +686,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -710,24 +709,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -742,8 +741,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -764,13 +763,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'history': list([ + ]), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -785,22 +785,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -815,8 +815,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -837,18 +837,19 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'history': list([ + ]), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -865,18 +866,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -891,7 +892,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -913,234 +914,239 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), - 'isDue': None, - 'nextDue': list([ - ]), - 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , - 'reminders': list([ - ]), - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'startDate': None, - 'streak': None, - 'tags': list([ - ]), - 'text': 'Garten pflegen', - 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), - 'value': 0.0, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': None, - }), - dict({ - 'Type': , - 'alias': None, - 'attribute': , - 'byHabitica': False, - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'counterDown': None, - 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), - 'daysOfMonth': list([ - ]), - 'down': None, - 'everyX': None, - 'frequency': None, - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'history': None, - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), - 'isDue': None, - 'nextDue': list([ - ]), - 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , - 'reminders': list([ - ]), - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'startDate': None, - 'streak': None, - 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), - ]), - 'text': 'Wochenendausflug planen', - 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), - 'value': 0.0, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': None, - }), - dict({ - 'Type': , - 'alias': None, - 'attribute': , - 'byHabitica': False, - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': None, - 'counterDown': None, - 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'date': None, - 'daysOfMonth': list([ - ]), - 'down': None, - 'everyX': None, - 'frequency': None, - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'history': None, - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), - 'isDue': None, - 'nextDue': list([ - ]), - 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , - 'reminders': list([ - ]), - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'startDate': None, - 'streak': None, - 'tags': list([ - ]), - 'text': 'Belohne Dich selbst', - 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), - 'value': 10.0, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': None, - }), - dict({ - 'Type': , - 'alias': None, - 'attribute': , - 'byHabitica': False, - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'counterDown': None, - 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), - 'date': None, - 'daysOfMonth': list([ - ]), - 'down': None, - 'everyX': 1, - 'frequency': , - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'type': 'todo', + 'up': None, + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + '51076966-2970-4b40-b6ba-d58c6a756dd7', + ]), + 'text': 'Wochenendausflug planen', + 'type': 'todo', + 'up': None, + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-07-07T17:51:53.266000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', + ]), + 'text': 'Belohne Dich selbst', + 'type': 'reward', + 'up': None, + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1152,14 +1158,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -1167,9 +1174,86 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-09-20T23:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), + dict({ + 'alias': None, + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1184,7 +1268,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1206,13 +1290,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'history': list([ + ]), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -1227,21 +1312,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1256,7 +1341,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1278,13 +1363,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'history': list([ + ]), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1299,12 +1385,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -1317,9 +1404,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1331,7 +1417,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1339,13 +1425,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1364,7 +1450,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1372,7 +1458,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1380,7 +1466,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1388,7 +1474,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1396,7 +1482,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1404,7 +1490,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1412,7 +1498,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1420,7 +1506,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1428,7 +1514,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1436,7 +1522,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1444,25 +1530,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1474,14 +1560,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -1494,9 +1581,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1511,13 +1597,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1535,12 +1621,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1557,18 +1643,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1583,13 +1669,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1608,19 +1694,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1637,9 +1723,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1652,9 +1739,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1666,7 +1752,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1674,13 +1760,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1698,18 +1784,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1721,24 +1807,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1753,8 +1839,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -1775,13 +1861,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'history': list([ + ]), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1796,13 +1883,14 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1815,9 +1903,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1832,13 +1919,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1857,7 +1944,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1865,7 +1952,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1873,7 +1960,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1881,7 +1968,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1889,7 +1976,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1897,7 +1984,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1905,7 +1992,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1913,7 +2000,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1921,7 +2008,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1929,30 +2016,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -1964,14 +2051,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), @@ -1984,9 +2072,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2001,13 +2088,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2026,19 +2113,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2055,18 +2142,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2078,7 +2165,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2086,13 +2173,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2111,7 +2198,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2119,7 +2206,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2127,7 +2214,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2135,7 +2222,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2143,7 +2230,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2151,7 +2238,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2159,7 +2246,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2167,7 +2254,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2175,7 +2262,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2183,7 +2270,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2191,25 +2278,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2221,14 +2308,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -2241,9 +2329,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2258,13 +2345,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2282,12 +2369,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2304,18 +2391,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2330,13 +2417,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2355,19 +2442,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2384,18 +2471,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2410,13 +2497,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2434,12 +2521,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2456,18 +2543,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2482,13 +2569,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2507,19 +2594,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2536,18 +2623,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2559,7 +2646,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2567,13 +2654,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2592,7 +2679,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2600,7 +2687,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2608,7 +2695,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2616,7 +2703,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2624,7 +2711,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2632,7 +2719,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2640,7 +2727,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2648,7 +2735,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2656,7 +2743,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2664,7 +2751,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2672,25 +2759,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2702,23 +2789,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2733,13 +2820,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2758,7 +2845,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2766,7 +2853,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2774,7 +2861,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2782,7 +2869,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2790,7 +2877,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2798,7 +2885,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2806,7 +2893,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2814,7 +2901,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2822,7 +2909,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2830,30 +2917,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2865,23 +2952,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2896,8 +2983,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -2918,13 +3005,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'history': list([ + ]), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2939,22 +3027,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2969,8 +3057,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -2991,18 +3079,19 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'history': list([ + ]), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -3019,18 +3108,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3045,7 +3134,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3067,234 +3156,239 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), - 'isDue': None, - 'nextDue': list([ - ]), - 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , - 'reminders': list([ - ]), - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'startDate': None, - 'streak': None, - 'tags': list([ - ]), - 'text': 'Garten pflegen', - 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), - 'value': 0.0, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': None, - }), - dict({ - 'Type': , - 'alias': None, - 'attribute': , - 'byHabitica': False, - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'counterDown': None, - 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), - 'daysOfMonth': list([ - ]), - 'down': None, - 'everyX': None, - 'frequency': None, - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'history': None, - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), - 'isDue': None, - 'nextDue': list([ - ]), - 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , - 'reminders': list([ - ]), - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'startDate': None, - 'streak': None, - 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), - ]), - 'text': 'Wochenendausflug planen', - 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), - 'value': 0.0, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': None, - }), - dict({ - 'Type': , - 'alias': None, - 'attribute': , - 'byHabitica': False, - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': None, - 'counterDown': None, - 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'date': None, - 'daysOfMonth': list([ - ]), - 'down': None, - 'everyX': None, - 'frequency': None, - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'history': None, - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), - 'isDue': None, - 'nextDue': list([ - ]), - 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , - 'reminders': list([ - ]), - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'startDate': None, - 'streak': None, - 'tags': list([ - ]), - 'text': 'Belohne Dich selbst', - 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), - 'value': 10.0, - 'weeksOfMonth': list([ - ]), - 'yesterDaily': None, - }), - dict({ - 'Type': , - 'alias': None, - 'attribute': , - 'byHabitica': False, - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'checklist': list([ - ]), - 'collapseChecklist': False, - 'completed': False, - 'counterDown': None, - 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), - 'date': None, - 'daysOfMonth': list([ - ]), - 'down': None, - 'everyX': 1, - 'frequency': , - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Rasen mähen und die Pflanzen gießen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + ]), + 'text': 'Garten pflegen', + 'type': 'todo', + 'up': None, + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + '51076966-2970-4b40-b6ba-d58c6a756dd7', + ]), + 'text': 'Wochenendausflug planen', + 'type': 'todo', + 'up': None, + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 0.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': None, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-07-07T17:51:53.266000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': None, + 'frequency': None, + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', + 'isDue': None, + 'nextDue': list([ + ]), + 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': True, + 's': False, + 'su': False, + 't': True, + 'th': False, + 'w': True, + }), + 'startDate': None, + 'streak': None, + 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', + ]), + 'text': 'Belohne Dich selbst', + 'type': 'reward', + 'up': None, + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': 10.0, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': None, + }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3306,20 +3400,99 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-09-20T23:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -3333,9 +3506,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3347,7 +3519,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -3355,13 +3527,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3379,18 +3551,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3402,24 +3574,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3434,7 +3606,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3456,13 +3628,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'history': list([ + ]), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3477,12 +3650,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3495,9 +3669,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3512,7 +3685,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3534,13 +3707,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'history': list([ + ]), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -3555,12 +3729,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3573,9 +3748,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3590,13 +3764,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3614,12 +3788,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3636,18 +3810,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3662,13 +3836,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3687,19 +3861,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3716,18 +3890,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3742,13 +3916,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3766,12 +3940,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3788,18 +3962,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3814,13 +3988,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3839,19 +4013,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3868,18 +4042,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3891,7 +4065,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -3899,13 +4073,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3924,7 +4098,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3932,7 +4106,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3940,7 +4114,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3948,7 +4122,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3956,7 +4130,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3964,7 +4138,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3972,7 +4146,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3980,7 +4154,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3988,7 +4162,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -3996,7 +4170,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4004,25 +4178,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4034,23 +4208,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4065,13 +4239,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4090,7 +4264,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4098,7 +4272,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4106,7 +4280,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4114,7 +4288,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4122,7 +4296,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4130,7 +4304,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4138,7 +4312,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4146,7 +4320,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4154,7 +4328,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4162,30 +4336,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4197,23 +4371,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4225,7 +4399,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4233,13 +4407,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4257,18 +4431,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -4280,24 +4454,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4312,13 +4486,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4336,18 +4510,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4359,20 +4533,99 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-09-20T23:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -4380,9 +4633,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4394,7 +4646,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4402,13 +4654,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4427,7 +4679,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4435,7 +4687,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4443,7 +4695,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4451,7 +4703,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4459,7 +4711,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4467,7 +4719,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4475,7 +4727,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4483,7 +4735,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4491,7 +4743,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4499,7 +4751,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4507,25 +4759,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4537,23 +4789,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4568,13 +4820,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4593,7 +4845,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4601,7 +4853,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4609,7 +4861,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4617,7 +4869,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4625,7 +4877,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4633,7 +4885,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4641,7 +4893,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4649,7 +4901,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4657,7 +4909,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4665,30 +4917,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4700,23 +4952,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4728,7 +4980,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4736,13 +4988,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4760,18 +5012,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -4783,24 +5035,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4815,13 +5067,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4839,18 +5091,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4862,20 +5114,99 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-10-10T15:57:14.304000+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'weekly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', + ]), + 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': False, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-09-20T23:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + ]), + 'yesterDaily': True, + }), ]), }) # --- @@ -4883,9 +5214,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4900,13 +5230,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4924,12 +5254,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4946,18 +5276,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4972,13 +5302,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4997,19 +5327,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5026,18 +5356,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5052,13 +5382,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5076,12 +5406,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5098,18 +5428,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5124,13 +5454,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5149,19 +5479,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5178,9 +5508,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -5193,9 +5524,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5210,7 +5540,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5232,13 +5562,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'history': list([ + ]), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5253,11 +5584,14 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), @@ -5270,9 +5604,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5287,8 +5620,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5309,13 +5642,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'history': list([ + ]), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5330,22 +5664,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5360,8 +5694,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5382,18 +5716,19 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'history': list([ + ]), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -5410,18 +5745,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5436,7 +5771,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5458,13 +5793,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'history': list([ + ]), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5481,18 +5817,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5507,8 +5843,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5529,13 +5865,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'history': list([ + ]), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5550,21 +5887,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5579,7 +5916,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5601,13 +5938,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'history': list([ + ]), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5622,20 +5960,22 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5650,7 +5990,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5672,13 +6012,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'history': list([ + ]), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -5693,21 +6034,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5722,7 +6063,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5744,13 +6085,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'history': list([ + ]), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -5765,12 +6107,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -5783,9 +6126,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5800,8 +6142,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5822,13 +6164,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'history': list([ + ]), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5843,22 +6186,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5873,8 +6216,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5895,18 +6238,19 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'history': list([ + ]), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -5923,18 +6267,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5949,7 +6293,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5971,13 +6315,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'history': list([ + ]), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5994,18 +6339,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6020,8 +6365,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6042,13 +6387,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'history': list([ + ]), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6063,21 +6409,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6092,7 +6438,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6114,13 +6460,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'history': list([ + ]), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6135,21 +6482,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6164,7 +6511,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6186,13 +6533,14 @@ 'managerNotes': None, 'taskId': None, }), - 'history': None, - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'history': list([ + ]), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6207,12 +6555,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), diff --git a/tests/components/habitica/snapshots/test_switch.ambr b/tests/components/habitica/snapshots/test_switch.ambr index a865df3a4f4..e8122f77c6e 100644 --- a/tests/components/habitica/snapshots/test_switch.ambr +++ b/tests/components/habitica/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 25976270622..88204d53ded 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -49,6 +49,12 @@ 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), + dict({ + 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', + 'status': 'needs_action', + 'summary': 'Lerne eine neue Programmiersprache', + 'uid': '7d92278b-9361-4854-83b6-0a66b57dce20', + }), ]), }), }) @@ -107,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,7 +151,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '4', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] @@ -154,6 +161,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index adce8dce080..dc1a155b541 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from habiticalib import HabiticaUserResponse, Skill import pytest @@ -215,12 +216,12 @@ async def test_button_press( [ ( ERROR_TOO_MANY_REQUESTS, - "Rate limit exceeded, try again later", + "Rate limit exceeded, try again in 5 seconds", HomeAssistantError, ), ( ERROR_BAD_REQUEST, - "Unable to connect to Habitica, try again later", + "Unable to connect to Habitica: reason", HomeAssistantError, ), ( @@ -228,6 +229,11 @@ async def test_button_press( "Unable to complete action, the required conditions are not met", ServiceValidationError, ), + ( + ClientError, + "Unable to connect to Habitica: ", + HomeAssistantError, + ), ], ) async def test_button_press_exceptions( diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index bd3287d3ea1..07678b031bc 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components.habitica.const import ( CONF_API_USER, DEFAULT_URL, DOMAIN, + SECTION_DANGER_ZONE, SECTION_REAUTH_API_KEY, SECTION_REAUTH_LOGIN, ) @@ -54,6 +55,13 @@ USER_INPUT_REAUTH_API_KEY = { SECTION_REAUTH_LOGIN: {}, SECTION_REAUTH_API_KEY: {CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382"}, } +USER_INPUT_RECONFIGURE = { + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + SECTION_DANGER_ZONE: { + CONF_URL: DEFAULT_URL, + CONF_VERIFY_SSL: True, + }, +} @pytest.mark.usefixtures("habitica") @@ -147,6 +155,35 @@ async def test_form_login_errors( assert result["result"].unique_id == TEST_API_USER +@pytest.mark.usefixtures("habitica") +async def test_form__already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort form login when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_ADVANCED_STEP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("habitica") async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -387,3 +424,112 @@ async def test_flow_reauth_errors( assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reauth_unique_id_mismatch(hass: HomeAssistant) -> None: + """Test reauth flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_URL: DEFAULT_URL, + CONF_API_USER: "371fcad5-0f9c-4211-931c-034a5d2a6213", + CONF_API_KEY: "cd0e5985-17de-4b4f-849e-5d506c5e4382", + }, + unique_id="371fcad5-0f9c-4211-931c-034a5d2a6213", + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_REAUTH_LOGIN, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("habitica") +async def test_flow_reconfigure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (ERROR_NOT_AUTHORIZED, "invalid_auth"), + (ERROR_BAD_REQUEST, "cannot_connect"), + (KeyError, "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + habitica: AsyncMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + habitica.get_user.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + habitica.get_user.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT_RECONFIGURE, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_API_KEY] == "cd0e5985-17de-4b4f-849e-5d506c5e4382" + assert config_entry.data[CONF_URL] == DEFAULT_URL + assert config_entry.data[CONF_VERIFY_SSL] is True + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py new file mode 100644 index 00000000000..17089f57bd7 --- /dev/null +++ b/tests/components/habitica/test_image.py @@ -0,0 +1,99 @@ +"""Tests for the Habitica image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from io import BytesIO +import sys +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.extensions.image import PNGImageSnapshotExtension + +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@pytest.mark.skipif( + sys.platform != "linux", reason="linux only" +) # Pillow output on win/mac is different +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test image platform.""" + freezer.move_to("2024-09-20T22:00:00.000") + with patch( + "homeassistant.components.habitica.coordinator.BytesIO", + ) as avatar: + avatar.side_effect = [ + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82" + ), + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82" + ), + ] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.test_user_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("rogue_fixture.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:01:00+00:00" + + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index ed2efd89f30..e953ec254d6 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -4,6 +4,7 @@ import datetime import logging from unittest.mock import AsyncMock +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest @@ -85,11 +86,12 @@ async def test_service_call( @pytest.mark.parametrize( ("exception"), - [ - ERROR_BAD_REQUEST, - ERROR_TOO_MANY_REQUESTS, + [ERROR_BAD_REQUEST, ERROR_TOO_MANY_REQUESTS, ClientError], + ids=[ + "BadRequestError", + "TooManyRequestsError", + "ClientError", ], - ids=["BadRequestError", "TooManyRequestsError"], ) async def test_config_entry_not_ready( hass: HomeAssistant, @@ -131,14 +133,16 @@ async def test_config_entry_auth_failed( assert flow["context"].get("entry_id") == config_entry.entry_id +@pytest.mark.parametrize("exception", [ERROR_NOT_FOUND, ClientError]) async def test_coordinator_update_failed( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, ) -> None: """Test coordinator update failed.""" - habitica.get_tasks.side_effect = ERROR_NOT_FOUND + habitica.get_tasks.side_effect = exception config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index 9dde266d214..1c648e38720 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -6,10 +6,13 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.habitica.sensor import HabiticaSensorEntity +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry, snapshot_platform @@ -33,6 +36,19 @@ async def test_sensors( ) -> None: """Test setup of the Habitica sensor platform.""" + for entity in ( + ("test_user_habits", "habits"), + ("test_user_rewards", "rewards"), + ("test_user_max_health", "health_max"), + ): + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{entity[1]}", + suggested_object_id=entity[0], + disabled_by=None, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -40,3 +56,96 @@ async def test_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_issue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deprecation issue.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=None, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert entity_registry.async_get(f"sensor.{entity_id}") is not None + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + + +@pytest.mark.parametrize( + ("entity_id", "key"), + [ + ("test_user_habits", HabiticaSensorEntity.HABITS), + ("test_user_rewards", HabiticaSensorEntity.REWARDS), + ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), + ], +) +@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") +async def test_sensor_deprecation_delete_disabled( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + entity_id: str, + key: HabiticaSensorEntity, +) -> None: + """Test sensor deletion .""" + + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", + suggested_object_id=entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + assert entity_registry is not None + with patch( + "homeassistant.components.habitica.sensor.entity_used_in", return_value=True + ): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{key}", + ) + is None + ) + + assert entity_registry.async_get(f"sensor.{entity_id}") is None diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3ada16b9735..10a8bc0a588 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -5,26 +5,44 @@ from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID -from habiticalib import Direction, Skill +from aiohttp import ClientError +from habiticalib import ( + Direction, + Frequency, + HabiticaTaskResponse, + Skill, + Task, + TaskPriority, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ALIAS, ATTR_CONFIG_ENTRY, + ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -32,8 +50,12 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, ) +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -44,10 +66,10 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture -REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" -RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" +REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" +RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @pytest.fixture(autouse=True) @@ -235,6 +257,15 @@ async def test_cast_skill( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) async def test_cast_skill_exceptions( @@ -360,6 +391,11 @@ async def test_handle_quests( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ], ) @pytest.mark.parametrize( @@ -520,6 +556,15 @@ async def test_score_task( HomeAssistantError, REQUEST_EXCEPTION_MSG, ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), ( { ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", @@ -722,7 +767,7 @@ async def test_transformation( ERROR_BAD_REQUEST, None, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, ), ( { @@ -752,7 +797,27 @@ async def test_transformation( None, ERROR_BAD_REQUEST, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + None, + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ( + { + ATTR_TARGET: "test-partymember-username", + ATTR_ITEM: "spooky_sparkles", + }, + ClientError, + None, + HomeAssistantError, + "Unable to connect to Habitica: ", ), ], ) @@ -845,3 +910,452 @@ async def test_get_tasks( ) assert response == snapshot + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.parametrize( + ("service", "task_id"), + [ + (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_update_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, + service: str, + task_id: str, +) -> None: + """Test Habitica task action exceptions.""" + + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + service, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_create_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task create action exceptions.""" + + habitica.create_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_NAME: "TITLE", + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.usefixtures("habitica") +async def test_task_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test Habitica task not found exceptions.""" + task_id = "7f902bbc-eb3d-4a8f-82cf-4e2025d69af1" + + with pytest.raises( + ServiceValidationError, + match="Unable to complete action, could not find the task '7f902bbc-eb3d-4a8f-82cf-4e2025d69af1'", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_COST: 100, + }, + Task(value=100), + ), + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update_reward action.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + ATTR_COST: 100, + }, + Task(type=TaskType.REWARD, text="TITLE", value=100), + ), + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.REWARD, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.REWARD, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.REWARD, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_reward action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_UP_DOWN: [""], + }, + Task(up=False, down=False), + ), + ( + { + ATTR_UP_DOWN: ["up"], + }, + Task(up=True, down=False), + ), + ( + { + ATTR_UP_DOWN: ["down"], + }, + Task(up=False, down=True), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_FREQUENCY: "daily", + }, + Task(frequency=Frequency.DAILY), + ), + ( + { + ATTR_COUNTER_UP: 1, + ATTR_COUNTER_DOWN: 2, + }, + Task(counterUp=1, counterDown=2), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica habit action.""" + task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +async def test_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding tags to a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Schule"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("2ac458af-0833-4f3f-bf04-98a0c33ef60b"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +async def test_create_new_tag( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding a non-existent tag and create it as new.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + habitica.create_tag.assert_awaited_with("Home Assistant") + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("8bc0afbf-ab8e-49a4-982d-67a40557ed1a"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +async def test_create_new_tag_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test create new tag exception.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.create_tag.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + +async def test_remove_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test removing tags from a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_REMOVE_TAG: ["Kreativität"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == {UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb")} diff --git a/tests/components/habitica/test_switch.py b/tests/components/habitica/test_switch.py index c259f53f183..1799788a48e 100644 --- a/tests/components/habitica/test_switch.py +++ b/tests/components/habitica/test_switch.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from syrupy.assertion import SnapshotAssertion @@ -96,6 +97,7 @@ async def test_turn_on_off_toggle( [ (ERROR_TOO_MANY_REQUESTS, HomeAssistantError), (ERROR_BAD_REQUEST, HomeAssistantError), + (ClientError, HomeAssistantError), ], ) async def test_turn_on_off_toggle_exceptions( diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index ea817013169..3457af78403 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -6,7 +6,13 @@ from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID -from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType +from habiticalib import ( + Direction, + HabiticaTaskOrderResponse, + HabiticaTasksResponse, + Task, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -23,10 +29,10 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import ERROR_NOT_FOUND +from .conftest import ERROR_NOT_FOUND, ERROR_TOO_MANY_REQUESTS from tests.common import ( MockConfigEntry, @@ -183,12 +189,30 @@ async def test_uncomplete_todo_item( ], ids=["completed", "needs_action"], ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + r"Unable to update the score for your Habitica to-do `.+`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_complete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, uid: str, status: str, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when completing/uncompleting an item on the todo list.""" @@ -198,10 +222,10 @@ async def test_complete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.update_score.side_effect = ERROR_NOT_FOUND + habitica.update_score.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match=r"Unable to update the score for your Habitica to-do `.+`, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -311,10 +335,28 @@ async def test_update_todo_item( habitica.update_task.assert_awaited_once_with(*call_args) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to update the Habitica to-do `test-summary`, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_update_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when update item on the todo list.""" uid = "88de7cd9-af2b-49ce-9afd-bf941d87336b" @@ -324,11 +366,8 @@ async def test_update_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.update_task.side_effect = ERROR_NOT_FOUND - with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to update the Habitica to-do `test-summary`, please try again", - ): + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception=expected_exception, match=exc_msg): await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, @@ -378,10 +417,28 @@ async def test_add_todo_item( ) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to create new to-do `test-summary` for Habitica, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_add_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when adding a todo item to the todo list.""" @@ -391,10 +448,11 @@ async def test_add_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.create_task.side_effect = ERROR_NOT_FOUND + habitica.create_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to create new to-do `test-summary` for Habitica, please try again", + expected_exception=expected_exception, + # match="Unable to create new to-do `test-summary` for Habitica, please try again", + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -434,10 +492,28 @@ async def test_delete_todo_item( habitica.delete_task.assert_awaited_once_with(UUID(uid)) +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete item from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting a todo item from the todo list.""" @@ -448,11 +524,11 @@ async def test_delete_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.delete_task.side_effect = ERROR_NOT_FOUND + habitica.delete_task.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete item from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -486,10 +562,28 @@ async def test_delete_completed_todo_items( habitica.delete_completed_todos.assert_awaited_once() +@pytest.mark.parametrize( + ("exception", "exc_msg", "expected_exception"), + [ + ( + ERROR_NOT_FOUND, + "Unable to delete completed to-do items from Habitica to-do list, please try again", + ServiceValidationError, + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + HomeAssistantError, + ), + ], +) async def test_delete_completed_todo_items_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, + exception: Exception, + exc_msg: str, + expected_exception: Exception, ) -> None: """Test exception when deleting completed todo items from the todo list.""" config_entry.add_to_hass(hass) @@ -498,10 +592,10 @@ async def test_delete_completed_todo_items_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.delete_completed_todos.side_effect = ERROR_NOT_FOUND + habitica.delete_completed_todos.side_effect = exception with pytest.raises( - expected_exception=ServiceValidationError, - match="Unable to delete completed to-do items from Habitica to-do list, please try again", + expected_exception=expected_exception, + match=exc_msg, ): await hass.services.async_call( TODO_DOMAIN, @@ -513,17 +607,23 @@ async def test_delete_completed_todo_items_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "previous_uid"), + ("entity_id", "uid", "second_pos", "third_pos", "fixture", "task_type"), [ ( "todo.test_user_to_do_s", "1aa3137e-ef72-4d1f-91ee-41933602f438", "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "reorder_todos_response.json", + "todos", ), ( "todo.test_user_dailies", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "reorder_dailies_response.json", + "dailys", ), ], ids=["todo", "daily"], @@ -535,10 +635,16 @@ async def test_move_todo_item( hass_ws_client: WebSocketGenerator, entity_id: str, uid: str, - previous_uid: str, + second_pos: str, + third_pos: str, + fixture: str, + task_type: str, ) -> None: """Test move todo items.""" - + reorder_response = HabiticaTaskOrderResponse.from_json( + load_fixture(fixture, DOMAIN) + ) + habitica.reorder_task.return_value = reorder_response config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -546,19 +652,36 @@ async def test_move_todo_item( assert config_entry.state is ConfigEntryState.LOADED client = await hass_ws_client() - # move to second position + # move up to second position data = { "id": id, "type": "todo/item/move", "entity_id": entity_id, "uid": uid, - "previous_uid": previous_uid, + "previous_uid": second_pos, } await client.send_json_auto_id(data) resp = await client.receive_json() assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) + + habitica.reorder_task.reset_mock() + + # move down to third position + data = { + "id": id, + "type": "todo/item/move", + "entity_id": entity_id, + "uid": uid, + "previous_uid": third_pos, + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") + + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 2) + habitica.reorder_task.reset_mock() # move to top position @@ -573,13 +696,32 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) + assert ( + getattr(config_entry.runtime_data.data.user.tasksOrder, task_type) + == reorder_response.data + ) +@pytest.mark.parametrize( + ("exception", "exc_msg"), + [ + ( + ERROR_NOT_FOUND, + "Unable to move the Habitica to-do to position 0, please try again", + ), + ( + ERROR_TOO_MANY_REQUESTS, + "Rate limit exceeded, try again in 5 seconds", + ), + ], +) async def test_move_todo_item_exception( hass: HomeAssistant, config_entry: MockConfigEntry, habitica: AsyncMock, hass_ws_client: WebSocketGenerator, + exception: Exception, + exc_msg: str, ) -> None: """Test exception when moving todo item.""" @@ -590,7 +732,7 @@ async def test_move_todo_item_exception( assert config_entry.state is ConfigEntryState.LOADED - habitica.reorder_task.side_effect = ERROR_NOT_FOUND + habitica.reorder_task.side_effect = exception client = await hass_ws_client() data = { @@ -605,10 +747,7 @@ async def test_move_todo_item_exception( habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) assert resp["success"] is False - assert ( - resp["error"]["message"] - == "Unable to move the Habitica to-do to position 0, please try again" - ) + assert resp["error"]["message"] == exc_msg @pytest.mark.parametrize( @@ -622,6 +761,7 @@ async def test_move_todo_item_exception( ("duedate_fixture_6.json", "2024-10-21"), ("duedate_fixture_7.json", None), ("duedate_fixture_8.json", None), + ("duedate_fixture_9.json", None), ], ids=[ "default", @@ -632,6 +772,7 @@ async def test_move_todo_item_exception( "monthly starts on fixed day", "grey daily", "empty nextDue", + "grey daily no weekdays", ], ) @pytest.mark.usefixtures("set_tz") diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index 1379bdba120..64fcda02df4 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -10,7 +10,7 @@ import psutil_home_assistant as ha_psutil from homeassistant.components.hardware.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index d87bfd32326..2233ad194f5 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import aiohttp from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.harmony.config_flow import CannotConnect from homeassistant.components.harmony.const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -65,7 +65,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -120,7 +120,7 @@ async def test_form_ssdp_fails_to_get_remote_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.12:8088/description", @@ -159,7 +159,7 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://2.2.2.2:8088/description", diff --git a/tests/components/hassio/snapshots/test_backup.ambr b/tests/components/hassio/snapshots/test_backup.ambr new file mode 100644 index 00000000000..725239ee126 --- /dev/null +++ b/tests/components/hassio/snapshots/test_backup.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_config_load_config_info[storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent1', + 'hassio.local', + 'test-agent2', + ]), + 'include_addons': list([ + 'addon1', + 'addon2', + ]), + 'include_all_addons': True, + 'include_database': True, + 'include_folders': list([ + 'media', + 'share', + ]), + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent1', + 'hassio.local', + 'test-agent2', + ]), + 'include_addons': list([ + 'addon1', + 'addon2', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + 'share', + ]), + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 10a804d983f..6e4fe4dd428 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -11,8 +11,10 @@ from dataclasses import replace from datetime import datetime from io import StringIO import os +from pathlib import PurePath from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,10 +23,14 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from aiohasupervisor.models.mounts import MountsInfo +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -33,9 +39,12 @@ from homeassistant.components.backup import ( BackupAgent, BackupAgentPlatformProtocol, Folder, + store as backup_store, ) -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP +from homeassistant.components.hassio import DOMAIN +from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON @@ -51,12 +60,12 @@ TEST_BACKUP = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, - locations={None}, + location_attributes={ + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -75,13 +84,9 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP.location, - locations=TEST_BACKUP.locations, + location_attributes=TEST_BACKUP.location_attributes, name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, slug=TEST_BACKUP.slug, supervisor_version="2024.11.2", type=TEST_BACKUP.type, @@ -91,16 +96,16 @@ TEST_BACKUP_2 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, - locations={None}, + location_attributes={ + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -116,16 +121,12 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( compressed=TEST_BACKUP_2.compressed, date=TEST_BACKUP_2.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_2.location, - locations=TEST_BACKUP_2.locations, + location_attributes=TEST_BACKUP_2.location_attributes, name=TEST_BACKUP_2.name, - protected=TEST_BACKUP_2.protected, repositories=[], - size=TEST_BACKUP_2.size, - size_bytes=TEST_BACKUP_2.size_bytes, slug=TEST_BACKUP_2.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_2.type, @@ -135,16 +136,16 @@ TEST_BACKUP_3 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location="share", - locations={"share"}, + location_attributes={ + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -160,16 +161,12 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( compressed=TEST_BACKUP_3.compressed, date=TEST_BACKUP_3.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_3.location, - locations=TEST_BACKUP_3.locations, + location_attributes=TEST_BACKUP_3.location_attributes, name=TEST_BACKUP_3.name, - protected=TEST_BACKUP_3.protected, repositories=[], - size=TEST_BACKUP_3.size, - size_bytes=TEST_BACKUP_3.size_bytes, slug=TEST_BACKUP_3.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_3.type, @@ -180,16 +177,16 @@ TEST_BACKUP_4 = supervisor_backups.Backup( compressed=False, content=supervisor_backups.BackupContent( addons=["ssl"], - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, - locations={None}, + location_attributes={ + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -202,22 +199,101 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( version="9.14.0", ) ], - compressed=TEST_BACKUP.compressed, - date=TEST_BACKUP.date, + compressed=TEST_BACKUP_4.compressed, + date=TEST_BACKUP_4.date, extra=None, - folders=["share"], + folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP.location, - locations=TEST_BACKUP.locations, - name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, + location_attributes=TEST_BACKUP_4.location_attributes, + name=TEST_BACKUP_4.name, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, - slug=TEST_BACKUP.slug, + slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", - type=TEST_BACKUP.type, + type=TEST_BACKUP_4.type, +) + +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + name="Test", + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location_attributes=TEST_BACKUP_5.location_attributes, + name=TEST_BACKUP_5.name, + repositories=[], + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), + child_jobs=[], +) +TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( + name="backup_manager_partial_restore", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[ + supervisor_jobs.JobError( + type="BackupInvalidError", + message=( + "Backup was made on supervisor version 2025.02.2.dev3105, " + "can't restore on 2025.01.2.dev3105" + ), + ) + ], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), + child_jobs=[], ) @@ -241,10 +317,11 @@ async def hassio_enabled( @pytest.fixture -async def setup_integration( +async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -252,11 +329,11 @@ async def setup_integration( class BackupAgentTest(BackupAgent): """Test backup agent.""" - domain = "test" - - def __init__(self, name: str) -> None: + def __init__(self, name: str, domain: str = "test") -> None: """Initialize the backup agent.""" + self.domain = domain self.name = name + self.unique_id = name async def async_download_backup( self, backup_id: str, **kwargs: Any @@ -304,7 +381,10 @@ async def _setup_backup_platform( @pytest.mark.parametrize( ("mounts", "expected_agents"), [ - (MountsInfo(default_backup_mount=None, mounts=[]), ["hassio.local"]), + ( + MountsInfo(default_backup_mount=None, mounts=[]), + [BackupAgentTest("local", DOMAIN)], + ), ( MountsInfo( default_backup_mount=None, @@ -321,7 +401,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local", "hassio.test"], + [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], ), ( MountsInfo( @@ -339,7 +419,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local"], + [BackupAgentTest("local", DOMAIN)], ), ], ) @@ -348,12 +428,13 @@ async def test_agent_info( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, mounts: MountsInfo, - expected_agents: list[str], + expected_agents: list[BackupAgent], ) -> None: """Test backup agent info.""" client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await client.send_json_auto_id({"type": "backup/agents/info"}) @@ -361,11 +442,14 @@ async def test_agent_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": agent_id} for agent_id in expected_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in expected_agents + ], } -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("backup", "backup_details", "expected_response"), [ @@ -376,17 +460,16 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), @@ -397,17 +480,16 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["share"], "homeassistant_included": False, "homeassistant_version": None, "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), @@ -433,13 +515,13 @@ async def test_agent_list_backups( assert response["result"]["backups"] == [expected_response] -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -453,30 +535,40 @@ async def test_agent_download( assert await resp.content.read() == b"backup data" supervisor_client.backups.download_backup.assert_called_once_with( - "abc123", options=supervisor_backups.DownloadBackupOptions(location=None) + "abc123", + options=supervisor_backups.DownloadBackupOptions( + location=LOCATION_LOCAL_STORAGE + ), ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info", "backup_id", "agent_id"), + [ + (TEST_BACKUP_DETAILS_3, "unknown", "hassio.local"), + (TEST_BACKUP_DETAILS_3, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + (TEST_BACKUP_DETAILS, TEST_BACKUP_DETAILS_3.slug, "hassio.local"), + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_download_unavailable_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, + agent_id: str, + backup_id: str, + backup_info: supervisor_backups.BackupComplete, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() - backup_id = "abc123" - supervisor_client.backups.list.return_value = [TEST_BACKUP_3] - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_3 - supervisor_client.backups.download_backup.return_value.__aiter__.return_value = ( - iter((b"backup data",)) - ) + supervisor_client.backups.backup_info.return_value = backup_info + supervisor_client.backups.download_backup.side_effect = SupervisorNotFoundError - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=hassio.local") + resp = await client.get(f"/api/backup/download/{backup_id}?agent_id={agent_id}") assert resp.status == 404 -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -525,7 +617,93 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "extra_metadata": {}, + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": True, + "result": {"agent_errors": {"hassio.local": "blah"}, "backup": None}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_delete_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -546,26 +724,22 @@ async def test_agent_delete_backup( assert response["success"] assert response["result"] == {"agent_errors": {}} supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("remove_side_effect", "expected_response"), [ ( SupervisorBadRequestError("blah"), - { - "success": False, - "error": {"code": "unknown_error", "message": "Unknown error"}, - }, - ), - ( - SupervisorBadRequestError("Backup does not exist"), { "success": True, - "result": {"agent_errors": {}}, + "result": {"agent_errors": {"hassio.local": "blah"}}, }, ), ( @@ -599,11 +773,14 @@ async def test_agent_delete_with_error( assert response == {"id": 1, "type": "result"} | expected_response supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("event_data", "mount_info_calls"), [ @@ -671,18 +848,20 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( compressed=True, extra={ "instance_id": ANY, + "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - folders=None, + filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), + folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, - location=[None], + location=[LOCATION_LOCAL_STORAGE], name="Test", password=None, ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("extra_generate_options", "expected_supervisor_options"), [ @@ -704,7 +883,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), + replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), ), ( { @@ -724,14 +903,17 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( async def test_reader_writer_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -746,13 +928,14 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -763,7 +946,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -773,6 +956,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -780,6 +964,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -791,16 +976,420 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_create_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": False, "stage": "addons"}, + supervisor_event_base | {"done": True, "stage": "finishing_file"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + "finishing_file", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( - ("side_effect", "error_code", "error_message"), + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [LOCATION_LOCAL_STORAGE], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [LOCATION_LOCAL_STORAGE, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [LOCATION_LOCAL_STORAGE, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [LOCATION_LOCAL_STORAGE], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + extra=DEFAULT_BACKUP_OPTIONS.extra, + location_attributes={ + location: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=1048576, + ) + for location in create_locations + }, + ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +@pytest.mark.parametrize( + ("side_effect", "error_code", "error_message", "expected_reason"), [ ( SupervisorError("Boom!"), "home_assistant_error", "Error creating backup: Boom!", + "backup_manager_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", ), - (Exception("Boom!"), "unknown_error", "Unknown error"), ], ) async def test_reader_writer_create_partial_backup_error( @@ -810,6 +1399,7 @@ async def test_reader_writer_create_partial_backup_error( side_effect: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test client partial backup error when generating a backup.""" client = await hass_ws_client(hass) @@ -827,6 +1417,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -834,6 +1425,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": expected_reason, "stage": None, "state": "failed", } @@ -849,15 +1441,45 @@ async def test_reader_writer_create_partial_backup_error( assert supervisor_client.backups.partial_backup.call_count == 1 -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + "supervisor_event", + [ + # Missing backup reference + { + "event": "job", + "data": { + "done": True, + "uuid": TEST_JOB_ID, + }, + }, + # Errors + { + "event": "job", + "data": { + "done": True, + "errors": [ + { + "type": "BackupMountDownError", + "message": "test_mount is down, cannot back-up to it", + } + ], + "uuid": TEST_JOB_ID, + "reference": "test_slug", + }, + }, + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_missing_reference_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + supervisor_event: dict[str, Any], ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -871,24 +1493,19 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } + {"type": "supervisor/event", "data": supervisor_event} ) response = await client.receive_json() assert response["success"] @@ -896,6 +1513,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -910,7 +1528,7 @@ async def test_reader_writer_create_missing_reference_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) @pytest.mark.parametrize( ("method", "download_call_count", "remove_call_count"), @@ -927,8 +1545,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -954,13 +1573,14 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -969,7 +1589,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -979,6 +1599,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -986,6 +1607,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1000,7 +1622,7 @@ async def test_reader_writer_create_download_remove_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")]) async def test_reader_writer_create_info_error( hass: HomeAssistant, @@ -1010,8 +1632,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1035,13 +1658,14 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1050,7 +1674,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1060,6 +1684,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1074,16 +1699,19 @@ async def test_reader_writer_create_info_error( assert response["event"] == {"manager_state": "idle"} -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + freezer.move_to("2025-01-30 13:42:12.345678") + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1107,16 +1735,17 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1124,7 +1753,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1134,6 +1763,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -1141,6 +1771,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1152,7 +1783,7 @@ async def test_reader_writer_create_remote_backup( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") @pytest.mark.parametrize( ("extra_generate_options", "expected_error"), [ @@ -1181,7 +1812,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1197,6 +1828,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1204,6 +1836,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "unknown_error", "stage": None, "state": "failed", } @@ -1220,7 +1853,7 @@ async def test_reader_writer_create_wrong_parameters( supervisor_client.backups.partial_backup.assert_not_called() -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_receive_remote_backup( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -1229,7 +1862,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -1283,17 +1916,33 @@ async def test_agent_receive_remote_backup( ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1309,6 +1958,7 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1320,26 +1970,123 @@ async def test_reader_writer_restore( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=LOCATION_LOCAL_STORAGE, + password=None, + ), + ) + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = [ + "addon_repositories", + "home_assistant", + "addons", + ] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1353,31 +2100,36 @@ async def test_reader_writer_restore( @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code"), + ("supervisor_error", "expected_error_code", "expected_reason"), [ ( - "Invalid password for backup", + SupervisorBadRequestError("Invalid password for backup"), + "password_incorrect", "password_incorrect", ), ( - "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", + SupervisorBadRequestError( + "Backup was made on supervisor version 2025.12.0, can't " + "restore on 2024.12.0. Must update supervisor first." + ), "home_assistant_error", + "unknown_error", ), + (SupervisorNotFoundError(), "backup_not_found", "backup_not_found"), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, - supervisor_error_string: str, + supervisor_error: Exception, expected_error_code: str, + expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.side_effect = SupervisorBadRequestError( - supervisor_error_string - ) + supervisor_client.backups.partial_restore.side_effect = supervisor_error supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS @@ -1393,6 +2145,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1404,7 +2157,7 @@ async def test_reader_writer_restore_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -1412,6 +2165,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": expected_reason, "stage": None, "state": "failed", } @@ -1423,6 +2177,97 @@ async def test_reader_writer_restore_error( assert response["error"]["code"] == expected_error_code +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_late_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup with error.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=LOCATION_LOCAL_STORAGE, + password=None, + ), + ) + + event = { + "event": "job", + "data": { + "name": "backup_manager_partial_restore", + "reference": "7c54aeed", + "uuid": TEST_JOB_ID, + "progress": 0, + "stage": None, + "done": True, + "parent_id": None, + "errors": [ + { + "type": "BackupInvalidError", + "message": ( + "Backup was made on supervisor version 2025.02.2.dev3105, can't" + " restore on 2025.01.2.dev3105. Must update supervisor first." + ), + } + ], + "created": "2025-02-03T08:27:49.297997+00:00", + }, + } + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": "backup_reader_writer_error", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": ( + "Restore failed: [{'type': 'BackupInvalidError', 'message': \"Backup " + "was made on supervisor version 2025.02.2.dev3105, can't restore on " + '2025.01.2.dev3105. Must update supervisor first."}]' + ), + } + + @pytest.mark.parametrize( ("backup", "backup_details", "parameters", "expected_error"), [ @@ -1446,7 +2291,7 @@ async def test_reader_writer_restore_error( ), ], ) -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_wrong_parameters( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1474,3 +2319,253 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.parametrize( + ("get_job_result", "last_non_idle_event"), + [ + ( + TEST_JOB_DONE, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + TEST_RESTORE_JOB_DONE_WITH_ERROR, + { + "manager_state": "restore_backup", + "reason": "unknown_error", + "stage": None, + "state": "failed", + }, + ), + ], +) +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + last_non_idle_event: dict[str, Any], +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = get_job_result + + async_initialize_backup(hass) + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == last_non_idle_event + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_report_progress( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + async_initialize_backup(hass) + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + response = await client.receive_json() + assert response["success"] + + supervisor_event_base = {"uuid": TEST_JOB_ID, "reference": "test_slug"} + supervisor_events = [ + supervisor_event_base | {"done": False, "stage": "addon_repositories"}, + supervisor_event_base | {"done": False, "stage": None}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "unknown"}, # Will be skipped + supervisor_event_base | {"done": False, "stage": "home_assistant"}, + supervisor_event_base | {"done": True, "stage": "addons"}, + ] + expected_manager_events = ["addon_repositories", "home_assistant", "addons"] + expected_manager_states = ["in_progress", "in_progress", "completed"] + + for supervisor_event in supervisor_events: + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": supervisor_event}, + } + ) + + acks = 0 + events = [] + for _ in range(len(supervisor_events) + len(expected_manager_events)): + response = await client.receive_json() + if "event" in response: + events.append(response) + continue + assert response["success"] + acks += 1 + + assert acks == len(supervisor_events) + assert len(events) == len(expected_manager_events) + + for i, event in enumerate(events): + assert event["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": expected_manager_events[i], + "state": expected_manager_states[i], + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": "addons", + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + async_initialize_backup(hass) + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" + + +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test-agent1", "hassio.local", "test-agent2"], + "include_addons": ["addon1", "addon2"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media", "share"], + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": backup_store.STORAGE_VERSION, + "minor_version": backup_store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test-agent1", "backup.local", "test-agent2"], + "include_addons": ["addon1", "addon2"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media", "share"], + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": backup_store.STORAGE_VERSION, + "minor_version": backup_store.STORAGE_VERSION_MINOR, + }, + }, + ], +) +@pytest.mark.usefixtures("hassio_client") +async def test_config_load_config_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + hass_storage: dict[str, Any], + storage_data: dict[str, Any] | None, +) -> None: + """Test loading stored backup config and reading it via config/info.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + hass_storage.update(storage_data) + + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index f8cac4e1a97..4c4f0e24dcc 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -17,7 +17,7 @@ from aiohasupervisor.models import ( import pytest from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 7160a2cbf16..f4b01a85900 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index c1775d6e0b4..a3718454538 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -2,18 +2,25 @@ from datetime import timedelta import os -from unittest.mock import AsyncMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError -from aiohasupervisor.models import StoreAddonUpdate +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from homeassistant.components.backup import BackupManagerError, ManagerBackup + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import AgentBackupStatus from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -216,12 +223,240 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non assert result await hass.async_block_till_done() - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.test_update"}, - blocking=True, + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": "hunter2", + }, + ), + ], +) +async def test_update_addon_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) @@ -264,13 +499,124 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> await hass.async_block_till_done() supervisor_client.homeassistant.update.return_value = None - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.home_assistant_core_update"}, - blocking=True, + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_core_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating core update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) ) - supervisor_client.homeassistant.update.assert_called_once() async def test_update_supervisor( @@ -325,6 +671,54 @@ async def test_update_addon_with_error( ) +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, r"^Error creating backup: "), + (None, BackupManagerError, r"^Error deleting old backups: "), + ], +) +async def test_update_addon_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, +) -> None: + """Test updating addon update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + pytest.raises(HomeAssistantError, match=message), + ): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + + async def test_update_os_with_error( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: @@ -406,6 +800,40 @@ async def test_update_core_with_error( ) +async def test_update_core_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating core update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + + async def test_release_notes_between_versions( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 21e6b03678b..b695cc1794a 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -1,9 +1,18 @@ """Test websocket API.""" -from unittest.mock import AsyncMock +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from homeassistant.components.backup import BackupManagerError, ManagerBackup + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import AgentBackupStatus +from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, ATTR_ENDPOINT, @@ -15,14 +24,18 @@ from homeassistant.components.hassio.const import ( WS_TYPE_API, WS_TYPE_SUBSCRIBE, ) +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component -from tests.common import MockUser, async_mock_signal +from tests.common import MockConfigEntry, MockUser, async_mock_signal from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + @pytest.fixture(autouse=True) def mock_all( @@ -56,7 +69,7 @@ def mock_all( ) aioclient_mock.get( "http://127.0.0.1/core/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, ) aioclient_mock.get( "http://127.0.0.1/os/info", @@ -64,11 +77,42 @@ def mock_all( ) aioclient_mock.get( "http://127.0.0.1/supervisor/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={ + "result": "ok", + "data": { + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + ], + }, + }, ) aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) @pytest.mark.usefixtures("hassio_env") @@ -279,3 +323,537 @@ async def test_websocket_non_admin_user( msg = await websocket_client.receive_json() assert msg["error"]["message"] == "Unauthorized" + + +async def test_update_addon( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, +) -> None: + """Test updating addon.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": False} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_not_called() + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": "hunter2", + }, + ), + ], +) +async def test_update_addon_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating addon with backup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with(**expected_kwargs) + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +async def test_update_core( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id({"type": "hassio/update/core", "backup": False}) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_not_called() + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_core_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating core with backup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +async def test_update_addon_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, +) -> None: + """Test updating addon with error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + update_addon.side_effect = SupervisorError + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": False} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error updating test: ", + } + + +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, "Error creating backup: "), + (None, BackupManagerError, "Error deleting old backups: "), + ], +) +async def test_update_addon_with_backup_and_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, +) -> None: + """Test updating addon with backup and error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == {"code": "home_assistant_error", "message": message} + + +async def test_update_core_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core with error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.side_effect = SupervisorError + await client.send_json_auto_id({"type": "hassio/update/core", "backup": False}) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error updating Home Assistant Core: ", + } + + +async def test_update_core_with_backup_and_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core with backup and error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await setup_backup_integration(hass) + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error creating backup: ", + } diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 3a774529c69..016cc7b3580 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -1 +1,66 @@ """Tests for the Heos component.""" + +from unittest.mock import AsyncMock + +from pyheos import ConnectionState, Heos, HeosGroup, HeosOptions, HeosPlayer + + +class MockHeos(Heos): + """Defines a mocked HEOS API.""" + + def __init__(self, options: HeosOptions) -> None: + """Initialize the mock.""" + super().__init__(options) + # Overwrite the methods with async mocks, changing type + self.add_to_queue: AsyncMock = AsyncMock() + self.connect: AsyncMock = AsyncMock() + self.disconnect: AsyncMock = AsyncMock() + self.get_favorites: AsyncMock = AsyncMock() + self.get_groups: AsyncMock = AsyncMock() + self.get_input_sources: AsyncMock = AsyncMock() + self.get_playlists: AsyncMock = AsyncMock() + self.get_players: AsyncMock = AsyncMock() + self.group_volume_down: AsyncMock = AsyncMock() + self.group_volume_up: AsyncMock = AsyncMock() + self.get_system_info: AsyncMock = AsyncMock() + self.load_players: AsyncMock = AsyncMock() + self.play_media: AsyncMock = AsyncMock() + self.play_preset_station: AsyncMock = AsyncMock() + self.play_url: AsyncMock = AsyncMock() + self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_play_next: AsyncMock = AsyncMock() + self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_set_mute: AsyncMock = AsyncMock() + self.player_set_play_mode: AsyncMock = AsyncMock() + self.player_set_play_state: AsyncMock = AsyncMock() + self.player_set_volume: AsyncMock = AsyncMock() + self.set_group: AsyncMock = AsyncMock() + self.set_group_volume: AsyncMock = AsyncMock() + self.sign_in: AsyncMock = AsyncMock() + self.sign_out: AsyncMock = AsyncMock() + + def mock_set_players(self, players: dict[int, HeosPlayer]) -> None: + """Set the players on the mock instance.""" + for player in players.values(): + player.heos = self + self._players = players + self._players_loaded = bool(players) + self.get_players.return_value = players + + def mock_set_groups(self, groups: dict[int, HeosGroup]) -> None: + """Set the groups on the mock instance.""" + for group in groups.values(): + group.heos = self + self._groups = groups + self._groups_loaded = bool(groups) + self.get_groups.return_value = groups + + def mock_set_signed_in_username(self, signed_in_username: str | None) -> None: + """Set the signed in status on the mock instance.""" + self._signed_in_username = signed_in_username + + def mock_set_connection_state(self, connection_state: ConnectionState) -> None: + """Set the connection state on the mock instance.""" + self._connection._state = connection_state diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 6451f5bc69e..7bed05a0289 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,226 +2,281 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Iterator from unittest.mock import Mock, patch from pyheos import ( - Dispatcher, - Heos, HeosGroup, + HeosHost, + HeosNowPlayingMedia, + HeosOptions, HeosPlayer, - HeosSource, - InputSource, + HeosSystem, + LineOutLevelType, + MediaItem, + MediaType, + NetworkType, + PlayerUpdateResult, + PlayState, + RepeatType, const, ) import pytest import pytest_asyncio -from homeassistant.components import ssdp -from homeassistant.components.heos import ( - CONF_PASSWORD, - DOMAIN, - ControllerManager, - GroupManager, - HeosRuntimeData, - SourceManager, +from homeassistant.components.heos import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, ) -from homeassistant.const import CONF_HOST, CONF_USERNAME + +from . import MockHeos from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") -def config_entry_fixture(heos_runtime_data): +def config_entry_fixture() -> MockConfigEntry: """Create a mock HEOS config entry.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="HEOS System (via 127.0.0.1)", unique_id=DOMAIN, ) - entry.runtime_data = heos_runtime_data - return entry @pytest.fixture(name="config_entry_options") -def config_entry_options_fixture(heos_runtime_data): +def config_entry_options_fixture() -> MockConfigEntry: """Create a mock HEOS config entry with options.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="HEOS System (via 127.0.0.1)", options={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, unique_id=DOMAIN, ) - entry.runtime_data = heos_runtime_data - return entry -@pytest.fixture(name="heos_runtime_data") -def heos_runtime_data_fixture(controller_manager, players): - """Create a mock HeosRuntimeData fixture.""" - return HeosRuntimeData( - controller_manager, Mock(GroupManager), Mock(SourceManager), players - ) +@pytest.fixture(name="new_mock", autouse=True) +def new_heos_mock_fixture(controller: MockHeos) -> Iterator[Mock]: + """Patch the Heos class to return the mock instance.""" + new_mock = Mock(return_value=controller) + with ( + patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), + ): + yield new_mock -@pytest.fixture(name="controller_manager") -def controller_manager_fixture(controller): - """Create a mock controller manager fixture.""" - mock_controller_manager = Mock(ControllerManager) - mock_controller_manager.controller = controller - return mock_controller_manager - - -@pytest.fixture(name="controller") -def controller_fixture( - players, favorites, input_sources, playlists, change_data, dispatcher, group -): +@pytest_asyncio.fixture(name="controller", autouse=True) +async def controller_fixture( + players: dict[int, HeosPlayer], + favorites: dict[int, MediaItem], + input_sources: list[MediaItem], + playlists: list[MediaItem], + change_data: PlayerUpdateResult, + group: dict[int, HeosGroup], + quick_selects: dict[int, str], +) -> MockHeos: """Create a mock Heos controller fixture.""" - mock_heos = Mock(Heos) - for player in players.values(): - player.heos = mock_heos - mock_heos.return_value = mock_heos - mock_heos.dispatcher = dispatcher - mock_heos.get_players.return_value = players - mock_heos.players = players + + mock_heos = MockHeos(HeosOptions(host="127.0.0.1")) + mock_heos.mock_set_signed_in_username("user@user.com") + mock_heos.mock_set_players(players) + mock_heos.mock_set_groups(group) mock_heos.get_favorites.return_value = favorites mock_heos.get_input_sources.return_value = input_sources mock_heos.get_playlists.return_value = playlists mock_heos.load_players.return_value = change_data - mock_heos.is_signed_in = True - mock_heos.signed_in_username = "user@user.com" - mock_heos.connection_state = const.STATE_CONNECTED - mock_heos.get_groups.return_value = group - mock_heos.create_group.return_value = None - - with ( - patch("homeassistant.components.heos.Heos", new=mock_heos), - patch("homeassistant.components.heos.config_flow.Heos", new=mock_heos), - ): - yield mock_heos + mock_heos.player_get_quick_selects.return_value = quick_selects + return mock_heos -@pytest.fixture(name="config") -def config_fixture(): - """Create hass config fixture.""" - return {DOMAIN: {CONF_HOST: "127.0.0.1"}} +@pytest.fixture(name="system") +def system_info_fixture() -> HeosSystem: + """Create a system info fixture.""" + main_host = HeosHost( + "Test Player", + "HEOS Drive HS2", + "123456", + "1.0.0", + "127.0.0.1", + NetworkType.WIRED, + True, + ) + return HeosSystem( + "user@user.com", + main_host, + hosts=[ + main_host, + HeosHost( + "Test Player 2", + "Speaker", + "123456", + "1.0.0", + "127.0.0.2", + NetworkType.WIFI, + True, + ), + ], + ) + + +@pytest.fixture(name="player_factory") +def player_factory_fixture() -> Callable[[int, str, str], HeosPlayer]: + """Return a method that creates players.""" + + def factory(player_id: int, name: str, model: str) -> HeosPlayer: + """Create a player.""" + return HeosPlayer( + player_id=player_id, + group_id=999, + name=name, + model=model, + serial="123456", + version="1.0.0", + supported_version=True, + line_out=LineOutLevelType.VARIABLE, + is_muted=False, + available=True, + state=PlayState.STOP, + ip_address=f"127.0.0.{player_id}", + network=NetworkType.WIRED, + shuffle=False, + repeat=RepeatType.OFF, + volume=25, + now_playing_media=HeosNowPlayingMedia( + type=MediaType.STATION, + song="Song", + station="Station Name", + album="Album", + artist="Artist", + image_url="http://", + album_id="1", + media_id="1", + queue_id=1, + source_id=10, + ), + ) + + return factory @pytest.fixture(name="players") -def player_fixture(quick_selects): +def players_fixture( + player_factory: Callable[[int, str, str], HeosPlayer], +) -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" - players = {} - for i in (1, 2): - player = Mock(HeosPlayer) - player.player_id = i - if i > 1: - player.name = f"Test Player {i}" - else: - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PLAY_STATE_STOP - player.ip_address = f"127.0.0.{i}" - player.network = "wired" - player.shuffle = False - player.repeat = const.REPEAT_OFF - player.volume = 25 - player.now_playing_media.supported_controls = const.CONTROLS_ALL - player.now_playing_media.album_id = 1 - player.now_playing_media.queue_id = 1 - player.now_playing_media.source_id = 1 - player.now_playing_media.station = "Station Name" - player.now_playing_media.type = "Station" - player.now_playing_media.album = "Album" - player.now_playing_media.artist = "Artist" - player.now_playing_media.media_id = "1" - player.now_playing_media.duration = None - player.now_playing_media.current_position = None - player.now_playing_media.image_url = "http://" - player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects - players[player.player_id] = player - return players + return { + 1: player_factory(1, "Test Player", "HEOS Drive HS2"), + 2: player_factory(2, "Test Player 2", "Speaker"), + } @pytest.fixture(name="group") -def group_fixture(players): +def group_fixture() -> dict[int, HeosGroup]: """Create a HEOS group consisting of two players.""" - group = Mock(HeosGroup) - group.leader = players[1] - group.members = [players[2]] - group.group_id = 999 + group = HeosGroup( + name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] + ) return {group.group_id: group} @pytest.fixture(name="favorites") -def favorites_fixture() -> dict[int, HeosSource]: +def favorites_fixture() -> dict[int, MediaItem]: """Create favorites fixture.""" - station = Mock(HeosSource) - station.type = const.TYPE_STATION - station.name = "Today's Hits Radio" - station.media_id = "123456789" - radio = Mock(HeosSource) - radio.type = const.TYPE_STATION - radio.name = "Classical MPR (Classical Music)" - radio.media_id = "s1234" + station = MediaItem( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Today's Hits Radio", + media_id="123456789", + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) + radio = MediaItem( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="Classical MPR (Classical Music)", + media_id="s1234", + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ) return {1: station, 2: radio} @pytest.fixture(name="input_sources") -def input_sources_fixture() -> Sequence[InputSource]: +def input_sources_fixture() -> list[MediaItem]: """Create a set of input sources for testing.""" - source = Mock(InputSource) - source.player_id = 1 - source.input_name = const.INPUT_AUX_IN_1 - source.name = "HEOS Drive - Line In 1" - return [source] - - -@pytest_asyncio.fixture(name="dispatcher") -async def dispatcher_fixture() -> Dispatcher: - """Create a dispatcher for testing.""" - return Dispatcher() + return [ + MediaItem( + source_id=const.MUSIC_SOURCE_AUX_INPUT, + name="HEOS Drive - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ), + MediaItem( + source_id=const.MUSIC_SOURCE_AUX_INPUT, + name="Speaker - Line In 1", + media_id=const.INPUT_AUX_IN_1, + type=MediaType.STATION, + playable=True, + browsable=False, + image_url="", + heos=None, + ), + ] @pytest.fixture(name="discovery_data") -def discovery_data_fixture() -> dict: +def discovery_data_fixture() -> SsdpServiceInfo: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Office", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Office", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) @pytest.fixture(name="discovery_data_bedroom") -def discovery_data_fixture_bedroom() -> dict: +def discovery_data_fixture_bedroom() -> SsdpServiceInfo: """Return mock discovery data for testing.""" - return ssdp.SsdpServiceInfo( + return SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.2:60006/upnp/desc/aios_device/aios_device.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Bedroom", - ssdp.ATTR_UPNP_MANUFACTURER: "Denon", - ssdp.ATTR_UPNP_MODEL_NAME: "HEOS Drive", - ssdp.ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", - ssdp.ATTR_UPNP_SERIAL: None, - ssdp.ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-denon-com:device:AiosDevice:1", + ATTR_UPNP_FRIENDLY_NAME: "Bedroom", + ATTR_UPNP_MANUFACTURER: "Denon", + ATTR_UPNP_MODEL_NAME: "HEOS Drive", + ATTR_UPNP_MODEL_NUMBER: "DWSA-10 4.0", + ATTR_UPNP_SERIAL: None, + ATTR_UPNP_UDN: "uuid:e61de70c-2250-1c22-0080-0005cdf512be", }, ) @@ -240,21 +295,27 @@ def quick_selects_fixture() -> dict[int, str]: @pytest.fixture(name="playlists") -def playlists_fixture() -> Sequence[HeosSource]: +def playlists_fixture() -> list[MediaItem]: """Create favorites fixture.""" - playlist = Mock(HeosSource) - playlist.type = const.TYPE_PLAYLIST - playlist.name = "Awesome Music" + playlist = MediaItem( + source_id=const.MUSIC_SOURCE_PLAYLISTS, + name="Awesome Music", + type=MediaType.PLAYLIST, + playable=True, + browsable=True, + image_url="", + heos=None, + ) return [playlist] @pytest.fixture(name="change_data") -def change_data_fixture() -> dict: +def change_data_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {}, const.DATA_NEW: []} + return PlayerUpdateResult() @pytest.fixture(name="change_data_mapped_ids") -def change_data_mapped_ids_fixture() -> dict: +def change_data_mapped_ids_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {101: 1}, const.DATA_NEW: []} + return PlayerUpdateResult(updated_player_ids={1: 101}) diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..98ce8a7bcbf --- /dev/null +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -0,0 +1,388 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'connected_to_preferred_host': True, + 'host': dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'supported_version': True, + 'version': '1.0.0', + }), + 'hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'supported_version': True, + 'version': '1.0.0', + }), + dict({ + 'ip_address': '127.0.0.2', + 'model': 'Speaker', + 'name': 'Test Player 2', + 'network': 'wifi', + 'serial': '**REDACTED**', + 'supported_version': True, + 'version': '1.0.0', + }), + ]), + 'is_signed_in': True, + 'preferred_hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'supported_version': True, + 'version': '1.0.0', + }), + ]), + 'signed_in_username': '**REDACTED**', + }), + }) +# --- +# name: test_config_entry_diagnostics_error_getting_system + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'error': 'Not connected to device', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'device': dict({ + 'area_id': None, + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'identifiers': list([ + list([ + 'heos', + '1', + ]), + ]), + 'labels': list([ + ]), + 'manufacturer': 'HEOS', + 'model': 'Drive HS2', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'serial_number': '**REDACTED**', + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'area_id': None, + 'categories': dict({ + }), + 'config_subentry_id': None, + 'disabled_by': None, + 'entity_category': None, + 'entity_id': 'media_player.test_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_name': None, + 'platform': 'heos', + 'translation_key': None, + 'unique_id': '1', + }), + 'state': dict({ + 'attributes': dict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': '1', + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': 'music', + 'media_queue_id': 1, + 'media_source_id': 10, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': 'station', + 'repeat': 'off', + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'supported_features': 3079741, + 'volume_level': 0.25, + }), + 'context': dict({ + 'parent_id': None, + 'user_id': None, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }), + }), + ]), + 'player': dict({ + 'available': True, + 'control': 0, + 'group_id': 999, + 'ip_address': '127.0.0.1', + 'is_muted': False, + 'line_out': 1, + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '1', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://', + 'media_id': '1', + 'options': list([ + ]), + 'queue_id': 1, + 'song': 'Song', + 'source_id': 10, + 'station': 'Station Name', + 'supported_controls': list([ + 'play', + 'pause', + 'stop', + 'play_next', + 'play_previous', + ]), + 'type': 'station', + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': 'off', + 'serial': '**REDACTED**', + 'shuffle': False, + 'state': 'stop', + 'supported_version': True, + 'version': '1.0.0', + 'volume': 25, + }), + }) +# --- diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..88d27f2073a --- /dev/null +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_state_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': '1', + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': , + 'media_queue_id': 1, + 'media_source_id': 10, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': , + 'repeat': , + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'supported_features': , + 'volume_level': 0.25, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }) +# --- diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 45c2fbf4eb1..396c3743663 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,19 +1,33 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandFailedError, HeosError +from typing import Any + +from pyheos import ( + CommandAuthenticationError, + CommandFailedError, + ConnectionState, + HeosError, + HeosHost, + HeosSystem, + NetworkType, +) import pytest -from homeassistant.components import heos, ssdp from homeassistant.components.heos.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from . import MockHeos from tests.common import MockConfigEntry -async def test_flow_aborts_already_setup(hass: HomeAssistant, config_entry) -> None: +async def test_flow_aborts_already_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test flow aborts when entry already setup.""" config_entry.add_to_hass(hass) @@ -35,98 +49,153 @@ async def test_no_host_shows_form(hass: HomeAssistant) -> None: assert result["errors"] == {} -async def test_cannot_connect_shows_error_form(hass: HomeAssistant, controller) -> None: +async def test_cannot_connect_shows_error_form( + hass: HomeAssistant, controller: MockHeos +) -> None: """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 -async def test_create_entry_when_host_valid(hass: HomeAssistant, controller) -> None: +async def test_create_entry_when_host_valid( + hass: HomeAssistant, controller: MockHeos +) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data + DOMAIN, context={"source": SOURCE_USER}, data=data ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == DOMAIN - assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["title"] == "HEOS System" assert result["data"] == data assert controller.connect.call_count == 2 # Also called in async_setup_entry assert controller.disconnect.call_count == 1 -async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller +async def test_discovery( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, + discovery_data_bedroom: SsdpServiceInfo, + controller: MockHeos, + system: HeosSystem, ) -> None: - """Test result type is create entry when friendly name is valid.""" - hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} - data = {CONF_HOST: "Office (127.0.0.1)"} - + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_USER}, data=data + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == DOMAIN - assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["title"] == "HEOS System" assert result["data"] == {CONF_HOST: "127.0.0.1"} - assert controller.connect.call_count == 2 # Also called in async_setup_entry - assert controller.disconnect.call_count == 1 - assert DOMAIN not in hass.data - - -async def test_discovery_shows_create_form( - hass: HomeAssistant, - controller, - discovery_data: ssdp.SsdpServiceInfo, - discovery_data_bedroom: ssdp.SsdpServiceInfo, -) -> None: - """Test discovery shows form to confirm setup.""" - - # Single discovered host shows form for user to finish setup. - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data - ) - assert hass.data[DOMAIN] == {"Office (127.0.0.1)": "127.0.0.1"} - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - # Subsequent discovered hosts append to discovered hosts and abort. - result = await hass.config_entries.flow.async_init( - heos.DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom - ) - assert hass.data[DOMAIN] == { - "Office (127.0.0.1)": "127.0.0.1", - "Bedroom (127.0.0.2)": "127.0.0.2", - } - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_in_progress" async def test_discovery_flow_aborts_already_setup( - hass: HomeAssistant, controller, discovery_data: ssdp.SsdpServiceInfo, config_entry + hass: HomeAssistant, + discovery_data_bedroom: SsdpServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, ) -> None: - """Test discovery flow aborts when entry already setup.""" + """Test discovery flow aborts when entry already setup and hosts didn't change.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_discovery_aborts_same_system( + hass: HomeAssistant, + discovery_data_bedroom: SsdpServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_discovery_fails_to_connect_aborts( + hass: HomeAssistant, discovery_data: SsdpServiceInfo, controller: MockHeos +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + +async def test_discovery_updates( + hass: HomeAssistant, + discovery_data_bedroom: SsdpServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" async def test_reconfigure_validates_and_updates_config( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure validates host and successfully updates.""" config_entry.add_to_hass(hass) @@ -134,9 +203,9 @@ async def test_reconfigure_validates_and_updates_config( assert config_entry.data[CONF_HOST] == "127.0.0.1" # Test reconfigure initially shows form with current host value. - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.1" assert result["errors"] == {} assert result["step_id"] == "reconfigure" @@ -156,7 +225,7 @@ async def test_reconfigure_validates_and_updates_config( async def test_reconfigure_cannot_connect_recovers( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure cannot connect and recovers.""" controller.connect.side_effect = HeosError() @@ -171,11 +240,13 @@ async def test_reconfigure_cannot_connect_recovers( assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.2" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM @@ -199,27 +270,24 @@ async def test_reconfigure_cannot_connect_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - ( - CommandFailedError("sign_in", "User not logged in", 8), - "invalid_auth", - ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], ) async def test_options_flow_signs_in( hass: HomeAssistant, - config_entry, - controller, + config_entry: MockConfigEntry, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: """Test options flow signs-in with entered credentials.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. assert CONF_USERNAME not in config_entry.options @@ -254,10 +322,12 @@ async def test_options_flow_signs_in( async def test_options_flow_signs_out( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -266,7 +336,7 @@ async def test_options_flow_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input @@ -298,13 +368,15 @@ async def test_options_flow_signs_out( ) async def test_options_flow_missing_one_param_recovers( hass: HomeAssistant, - config_entry, - controller, + config_entry: MockConfigEntry, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: """Test options flow signs-in after recovering from only username or password being entered.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. assert CONF_USERNAME not in config_entry.options @@ -333,18 +405,93 @@ async def test_options_flow_missing_one_param_recovers( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_options_flow_sign_in_setup_error_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be updated when the integration failed to set up.""" + config_entry.add_to_hass(hass) + controller.get_players.side_effect = ValueError("Unexpected error") + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == user_input + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_sign_out_setup_error_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be cleared when the integration failed to set up.""" + config_entry.add_to_hass(hass) + controller.get_players.side_effect = ValueError("Unexpected error") + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == {} + assert result["data"] == {} + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_sign_in_not_connected_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be updated when not connected to the HEOS device.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == user_input + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_sign_out_not_connected_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be cleared when not connected to the HEOS device.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == {} + assert result["data"] == {} + assert result["type"] is FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - ( - CommandFailedError("sign_in", "User not logged in", 8), - "invalid_auth", - ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], @@ -352,13 +499,16 @@ async def test_options_flow_missing_one_param_recovers( async def test_reauth_signs_in_aborts( hass: HomeAssistant, config_entry: MockConfigEntry, - controller, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: """Test reauth flow signs-in with entered credentials and aborts.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -390,17 +540,22 @@ async def test_reauth_signs_in_aborts( assert result["type"] is FlowResultType.ABORT -async def test_reauth_signs_out(hass: HomeAssistant, config_entry, controller) -> None: +async def test_reauth_signs_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input @@ -434,16 +589,19 @@ async def test_reauth_signs_out(hass: HomeAssistant, config_entry, controller) - ) async def test_reauth_flow_missing_one_param_recovers( hass: HomeAssistant, - config_entry, - controller, + config_entry: MockConfigEntry, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: """Test reauth flow signs-in after recovering from only username or password being entered.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. result = await config_entry.start_reauth_flow(hass) + assert config_entry.state is ConfigEntryState.LOADED assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} assert result["type"] is FlowResultType.FORM @@ -467,3 +625,51 @@ async def test_reauth_flow_missing_one_param_recovers( assert config_entry.options[CONF_PASSWORD] == user_input[CONF_PASSWORD] assert result["reason"] == "reauth_successful" assert result["type"] is FlowResultType.ABORT + + +async def test_reauth_updates_when_not_connected( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test reauth flow signs-in with entered credentials and aborts.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-in, updates options, and aborts + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options[CONF_USERNAME] == user_input[CONF_USERNAME] + assert config_entry.options[CONF_PASSWORD] == user_input[CONF_PASSWORD] + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + + +async def test_reauth_clears_when_not_connected( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test reauth flow signs-out with entered credentials and aborts.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-out, updates options, and aborts + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == {} + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py new file mode 100644 index 00000000000..a5341ef8d83 --- /dev/null +++ b/tests/components/heos/test_diagnostics.py @@ -0,0 +1,98 @@ +"""Tests for the HEOS diagnostics module.""" + +from pyheos import HeosError, HeosSystem +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.heos.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MockHeos + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + controller: MockHeos, + system: HeosSystem, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + controller.get_system_info.return_value = system + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_config_entry_diagnostics_error_getting_system( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + controller: MockHeos, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics with error during getting system info.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + controller.get_system_info.side_effect = HeosError("Not connected to device") + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device is not None + diagnostics = await get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) + assert diagnostics == snapshot( + exclude=props( + "created_at", + "modified_at", + "config_entries", + "config_entries_subentries", + "id", + "primary_config_entry", + "config_entry_id", + "device_id", + "entity_picture_local", + "last_changed", + "last_reported", + "last_updated", + ) + ) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 905346b8b4a..87cc8dd7dde 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,89 +1,66 @@ """Tests for the init module.""" -import asyncio +from collections.abc import Callable from typing import cast -from unittest.mock import Mock, patch +from unittest.mock import Mock -from pyheos import CommandFailedError, HeosError, const +from pyheos import ( + HeosError, + HeosOptions, + HeosPlayer, + PlayerUpdateResult, + SignalHeosEvent, + SignalType, + const, +) import pytest -from homeassistant.components.heos import ( - ControllerManager, - HeosOptions, - HeosRuntimeData, - async_setup_entry, - async_unload_entry, -) from homeassistant.components.heos.const import DOMAIN +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from . import MockHeos + from tests.common import MockConfigEntry - - -async def test_async_setup_returns_true( - hass: HomeAssistant, config_entry, config -) -> None: - """Test component setup from config.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0] == config_entry - - -async def test_async_setup_no_config_returns_true( - hass: HomeAssistant, config_entry -) -> None: - """Test component setup from entry only.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0] == config_entry +from tests.typing import WebSocketGenerator async def test_async_setup_entry_loads_platforms( - hass: HomeAssistant, config_entry, controller, input_sources, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await async_setup_entry(hass, config_entry) - # Assert platforms loaded - await hass.async_block_till_done() - assert forward_mock.call_count == 1 - assert controller.connect.call_count == 1 - assert controller.get_players.call_count == 1 - assert controller.get_favorites.call_count == 1 - assert controller.get_input_sources.call_count == 1 - controller.disconnect.assert_not_called() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.get("media_player.test_player") is not None + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 1 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() async def test_async_setup_entry_with_options_loads_platforms( hass: HomeAssistant, - config_entry_options, - config, - controller, - input_sources, - favorites, + config_entry_options: MockConfigEntry, + controller: MockHeos, + new_mock: Mock, ) -> None: """Test load connects to heos with options, retrieves players, and loads platforms.""" config_entry_options.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(config_entry_options.entry_id) # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.call_args[0][0]) + options = cast(HeosOptions, new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] + assert options.credentials is not None assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] assert controller.connect.call_count == 1 @@ -96,23 +73,21 @@ async def test_async_setup_entry_with_options_loads_platforms( async def test_async_setup_entry_auth_failure_starts_reauth( hass: HomeAssistant, config_entry_options: MockConfigEntry, - controller: Mock, + controller: MockHeos, ) -> None: """Test load with auth failure starts reauth, loads platforms.""" config_entry_options.add_to_hass(hass) # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller.is_signed_in = False - controller.signed_in_username = None - controller.dispatcher.send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + controller.mock_set_signed_in_username(None) + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) controller.connect.side_effect = connect_send_auth_failure - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(config_entry_options.entry_id) # Assert entry loaded and reauth flow started assert controller.connect.call_count == 1 @@ -120,33 +95,25 @@ async def test_async_setup_entry_auth_failure_starts_reauth( controller.disconnect.assert_not_called() assert config_entry_options.state is ConfigEntryState.LOADED assert any( - config_entry_options.async_get_active_flows(hass, sources=[SOURCE_REAUTH]) + config_entry_options.async_get_active_flows(hass, sources={SOURCE_REAUTH}) ) async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, - config_entry, - controller, - input_sources, + config_entry: MockConfigEntry, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller.is_signed_in = False - controller.signed_in_username = None - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await async_setup_entry(hass, config_entry) - # Assert platforms loaded - await hass.async_block_till_done() - assert forward_mock.call_count == 1 - assert controller.connect.call_count == 1 - assert controller.get_players.call_count == 1 - assert controller.get_favorites.call_count == 0 - assert controller.get_input_sources.call_count == 1 - controller.disconnect.assert_not_called() + controller.mock_set_signed_in_username(None) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 0 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() assert ( "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" in caplog.text @@ -154,68 +121,208 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( async def test_async_setup_entry_connect_failure( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.connect.side_effect = HeosError() - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_async_setup_entry_player_failure( - hass: HomeAssistant, config_entry, controller + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: - """Failure to retrieve players/sources raises ConfigEntryNotReady.""" + """Failure to retrieve players raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) controller.get_players.side_effect = HeosError() - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - controller.connect.reset_mock() - controller.disconnect.reset_mock() + assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant, config_entry, controller) -> None: - """Test entries are unloaded correctly.""" - controller_manager = Mock(ControllerManager) - config_entry.runtime_data = HeosRuntimeData(controller_manager, None, None, {}) - - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as unload: - assert await async_unload_entry(hass, config_entry) - await hass.async_block_till_done() - assert controller_manager.disconnect.call_count == 1 - assert unload.call_count == 1 - assert DOMAIN not in hass.data - - -async def test_update_sources_retry( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, +async def test_async_setup_entry_favorites_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: - """Test update sources retries on failures to max attempts.""" + """Failure to retrieve favorites loads.""" config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - controller.get_favorites.reset_mock() - controller.get_input_sources.reset_mock() - source_manager = config_entry.runtime_data.source_manager - source_manager.retry_delay = 0 - source_manager.max_retry_attempts = 1 - controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) - controller.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + controller.get_favorites.side_effect = HeosError() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_async_setup_entry_inputs_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Failure to retrieve inputs loads.""" + config_entry.add_to_hass(hass) + controller.get_input_sources.side_effect = HeosError() + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test entries are unloaded correctly.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert controller.disconnect.call_count == 1 + + +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test device information populates correctly.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device is not None + assert device.manufacturer == "HEOS" + assert device.model == "Drive HS2" + assert device.name == "Test Player" + assert device.serial_number == "123456" + assert device.sw_version == "1.0.0" + device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device is not None + assert device.manufacturer == "HEOS" + assert device.model == "Speaker" + + +async def test_device_id_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string device identifiers are migrated to strings.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1), ("Other", "1")}, # type: ignore[arg-type] ) - # Wait until it's finished - while "Unable to update sources" not in caplog.text: - await asyncio.sleep(0.1) - assert controller.get_favorites.call_count == 2 + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("Other", 1)}, # type: ignore[arg-type] + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert device_registry.async_get_device({("Other", 1)}) is not None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None + assert device_registry.async_get_device({("Other", "1")}) is not None + + +async def test_device_id_migration_both_present( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string devices are removed when both devices present.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier AND a new identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "1")} + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None + + +@pytest.mark.parametrize( + ("player_id", "expected_result"), + [("1", False), ("5", True)], + ids=("Present device", "Stale device"), +) +async def test_remove_config_entry_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, + player_id: str, + expected_result: bool, +) -> None: + """Test manually removing an stale device.""" + assert await async_setup_component(hass, "config", {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, player_id)} + ) + + ws_client = await hass_ws_client(hass) + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] == expected_result + + +async def test_reconnected_new_entities_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test new entities are created for new players after reconnecting.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert initial entity doesn't exist + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + # Create player + players = controller.players.copy() + players[3] = player_factory(3, "Test Player 3", "HEOS Link") + controller.mock_set_players(players) + controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + + # Simulate reconnection + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert new entity created + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + +async def test_players_changed_new_entities_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test new entities are created for new players on change event.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert initial entity doesn't exist + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + # Create player + players = controller.players.copy() + players[3] = player_factory(3, "Test Player 3", "HEOS Link") + controller.mock_set_players(players) + + # Simulate players changed event + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, + const.EVENT_PLAYERS_CHANGED, + PlayerUpdateResult([3], [], {}), + ) + await hass.async_block_till_done() + + # Assert new entity created + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 355cb47a0d9..3e755a29a0a 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1,28 +1,45 @@ """Tests for the Heos Media Player platform.""" -import asyncio +from datetime import timedelta +import re from typing import Any -from pyheos import CommandFailedError, const -from pyheos.error import HeosError +from freezegun.api import FrozenDateTimeFactory +from pyheos import ( + AddCriteriaType, + CommandFailedError, + HeosError, + MediaItem, + MediaType as HeosMediaType, + PlayerUpdateResult, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, + const, +) import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.heos import media_player -from homeassistant.components.heos.const import DOMAIN, SIGNAL_HEOS_UPDATED +from homeassistant.components.heos.const import ( + DOMAIN, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, +) from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, @@ -31,18 +48,17 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, - MediaPlayerEntityFeature, MediaType, + RepeatMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -51,98 +67,72 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from . import MockHeos - -async def setup_platform( - hass: HomeAssistant, config_entry: MockConfigEntry, config: dict[str, Any] -) -> None: - """Set up the media player platform for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() +from tests.common import MockConfigEntry, async_fire_time_changed async def test_state_attributes( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Tests the state attributes.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) state = hass.states.get("media_player.test_player") - assert state.state == STATE_IDLE - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.25 - assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED] - assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "1" - assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC - assert ATTR_MEDIA_DURATION not in state.attributes - assert ATTR_MEDIA_POSITION not in state.attributes - assert state.attributes[ATTR_MEDIA_TITLE] == "Song" - assert state.attributes[ATTR_MEDIA_ARTIST] == "Artist" - assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Album" - assert not state.attributes[ATTR_MEDIA_SHUFFLE] - assert state.attributes["media_album_id"] == 1 - assert state.attributes["media_queue_id"] == 1 - assert state.attributes["media_source_id"] == 1 - assert state.attributes["media_station"] == "Station Name" - assert state.attributes["media_type"] == "Station" - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Player" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | media_player.BASE_SUPPORTED_FEATURES - ) - assert ATTR_INPUT_SOURCE not in state.attributes - assert ( - state.attributes[ATTR_INPUT_SOURCE_LIST] - == config_entry.runtime_data.source_manager.source_list + assert state == snapshot( + exclude=props( + "entity_picture_local", + "context", + "last_changed", + "last_reported", + "last_updated", + ) ) async def test_updates_from_signals( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Tests dispatched signals update player.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test player does not update for other players - player.state = const.PLAY_STATE_PLAY - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED + player.state = PlayState.PLAY + await controller.dispatcher.wait_send( + SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE # Test player_update standard events - player.state = const.PLAY_STATE_PLAY - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + player.state = PlayState.PLAY + await controller.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_PLAYING # Test player_update progress events player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + await controller.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None assert state.attributes[ATTR_MEDIA_DURATION] == 360 assert state.attributes[ATTR_MEDIA_POSITION] == 1 @@ -150,144 +140,163 @@ async def test_updates_from_signals( async def test_updates_from_connection_event( hass: HomeAssistant, - config_entry, - config, - controller, + config_entry: MockConfigEntry, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Tests player updates from connection event after connection failure.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) # Connected player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) - await event.wait() + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 # Disconnected - event.clear() - player.reset_mock() controller.load_players.reset_mock() player.available = False - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) - await event.wait() + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_UNAVAILABLE assert controller.load_players.call_count == 0 # Connected handles refresh failure - event.clear() - player.reset_mock() controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) + controller.load_players.side_effect = CommandFailedError("", "Failure", 1) player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) - await event.wait() + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 assert "Unable to refresh players" in caplog.text +async def test_updates_from_connection_event_new_player_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + change_data_mapped_ids: PlayerUpdateResult, +) -> None: + """Test player ids changed after reconnection updates ids.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert current IDs + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") + + # Send event which will result in updated IDs. + controller.load_players.return_value = change_data_mapped_ids + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert updated IDs and previous don't exist + assert not device_registry.async_get_device(identifiers={(DOMAIN, "1")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") + + async def test_updates_from_sources_updated( - hass: HomeAssistant, config_entry, config, controller, input_sources + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in sources list.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - event = asyncio.Event() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - - input_sources.clear() - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + controller.get_input_sources.return_value = [] + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) - await event.wait() - source_list = config_entry.runtime_data.source_manager.source_list - assert len(source_list) == 2 + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list + assert state is not None + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "Today's Hits Radio", + "Classical MPR (Classical Music)", + ] async def test_updates_from_players_changed( hass: HomeAssistant, - config_entry, - config, - controller, - change_data, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: MockHeos, + change_data: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - event = asyncio.Event() - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - - assert hass.states.get("media_player.test_player").state == STATE_IDLE - player.state = const.PLAY_STATE_PLAY - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_IDLE + player.state = PlayState.PLAY + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) - await event.wait() await hass.async_block_till_done() - assert hass.states.get("media_player.test_player").state == STATE_PLAYING + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_PLAYING async def test_updates_from_players_changed_new_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, - config_entry, - config, - controller, - change_data_mapped_ids, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: MockHeos, + change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - event = asyncio.Event() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Assert device registry matches current id - assert device_registry.async_get_device(identifiers={(DOMAIN, 1)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) # Assert entity registry matches current id assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") == "media_player.test_player" ) - # Trigger update - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, ) - await event.wait() + await hass.async_block_till_done() # Assert device registry identifiers were updated assert len(device_registry.devices) == 2 - assert device_registry.async_get_device(identifiers={(DOMAIN, 101)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) # Assert entity registry unique id was updated assert len(entity_registry.entities) == 2 assert ( @@ -297,251 +306,552 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in user.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - event = asyncio.Event() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) - async def set_signal(): - event.set() - - async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - - controller.is_signed_in = False - controller.signed_in_username = None - player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None + controller.mock_set_signed_in_username(None) + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) - await event.wait() - source_list = config_entry.runtime_data.source_manager.source_list - assert len(source_list) == 1 + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list + assert state is not None + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "HEOS Drive - Line In 1", + "Speaker - Line In 1", + ] + + +async def test_updates_from_groups_changed( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test player updates from changes to groups.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Assert current state + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + + # Clear group information + controller.mock_set_groups({}) + for player in controller.players.values(): + player.group_id = None + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, const.EVENT_GROUPS_CHANGED, None + ) + await hass.async_block_till_done() + + # Assert groups changed + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None + + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None async def test_clear_playlist( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the clear playlist service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert controller.player_clear_queue.call_count == 1 + + +async def test_clear_playlist_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test error raised when clear playlist fails.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_clear_queue.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to clear playlist: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 - player.clear_queue.reset_mock() - player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to clear playlist: Failure (1)" in caplog.text + assert controller.player_clear_queue.call_count == 1 async def test_pause( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert controller.player_set_play_state.call_count == 1 + + +async def test_pause_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the pause service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to pause: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 - player.pause.reset_mock() - player.pause.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to pause: Failure (1)" in caplog.text + assert controller.player_set_play_state.call_count == 1 async def test_play( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert controller.player_set_play_state.call_count == 1 + + +async def test_play_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, match=re.escape("Unable to play: Failure (1)") + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 - player.play.reset_mock() - player.play.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play: Failure (1)" in caplog.text + assert controller.player_set_play_state.call_count == 1 async def test_previous_track( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert controller.player_play_previous.call_count == 1 + + +async def test_previous_track_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the previous track service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_play_previous.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move to previous track: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 - player.play_previous.reset_mock() - player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to move to previous track: Failure (1)" in caplog.text + assert controller.player_play_previous.call_count == 1 async def test_next_track( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert controller.player_play_next.call_count == 1 + + +async def test_next_track_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the next track service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_play_next.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move to next track: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 - player.play_next.reset_mock() - player.play_next.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to move to next track: Failure (1)" in caplog.text + assert controller.player_play_next.call_count == 1 async def test_stop( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert controller.player_set_play_state.call_count == 1 + + +async def test_stop_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the stop service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to stop: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 - player.stop.reset_mock() - player.stop.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to stop: Failure (1)" in caplog.text + assert controller.player_set_play_state.call_count == 1 async def test_volume_mute( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + assert controller.player_set_mute.call_count == 1 + + +async def test_volume_mute_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the volume mute service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_set_mute.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set mute: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 - player.set_mute.reset_mock() - player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set mute: Failure (1)" in caplog.text + assert controller.player_set_mute.call_count == 1 async def test_shuffle_set( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, + blocking=True, + ) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) + + +async def test_shuffle_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the shuffle set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set shuffle: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) - player.set_play_mode.reset_mock() - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set shuffle: Failure (1)" in caplog.text + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) + + +async def test_repeat_set( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the repeat set service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_REPEAT: RepeatMode.ONE}, + blocking=True, + ) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ONE, player.shuffle + ) + + +async def test_repeat_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the repeat set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set repeat: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_REPEAT: RepeatMode.ALL, + }, + blocking=True, + ) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ALL, player.shuffle + ) async def test_volume_set( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - # First pass completes successfully, second pass raises command error - for _ in range(2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.player_set_volume.assert_called_once_with(1, 100) + + +async def test_volume_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the volume set service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_set_volume.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set volume level: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) - player.set_volume.reset_mock() - player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to set volume level: Failure (1)" in caplog.text + controller.player_set_volume.assert_called_once_with(1, 100) + + +async def test_group_volume_set( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_called_once_with(999, 100) + + +async def test_group_volume_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service errors.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group_volume.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set group volume level: Failure (1)"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_called_once_with(999, 100) + + +async def test_group_volume_set_not_grouped_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service when not grouped raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.group_id = None + with pytest.raises( + ServiceValidationError, + match=re.escape("Entity media_player.test_player is not joined to a group"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_not_called() + + +async def test_group_volume_down( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume down service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + controller.group_volume_down.assert_called_with(999) + + +async def test_group_volume_up( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume up service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + controller.group_volume_up.assert_called_with(999) + + +@pytest.mark.parametrize( + "service", [SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_UP] +) +async def test_group_volume_down_up_ungrouped_raises( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + service: str, +) -> None: + """Test the group volume down and up service raise if player ungrouped.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.group_id = None + with pytest.raises( + ServiceValidationError, + match=re.escape("Entity media_player.test_player is not joined to a group"), + ): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + controller.group_volume_down.assert_not_called() + controller.group_volume_up.assert_not_called() async def test_select_favorite( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + favorites: dict[int, MediaItem], ) -> None: """Tests selecting a music service favorite and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set music service preset favorite = favorites[1] @@ -551,22 +861,28 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(1) + controller.play_preset_station.assert_called_once_with(1, 1) # Test state is matched by station name + player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = favorite.name - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + await controller.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite( - hass: HomeAssistant, config_entry, config, controller, favorites + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + favorites: dict[int, MediaItem], ) -> None: """Tests selecting a radio favorite and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] # Test set radio preset favorite = favorites[2] @@ -576,502 +892,429 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_favorite.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) # Test state is matched by album id + player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + await controller.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite_command_error( hass: HomeAssistant, - config_entry, - config, - controller, - favorites, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: MockHeos, + favorites: dict[int, MediaItem], ) -> None: - """Tests command error logged when playing favorite.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] + """Tests command error raises when playing favorite.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) # Test set radio preset favorite = favorites[2] - player.play_favorite.side_effect = CommandFailedError(None, "Failure", 1) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, - blocking=True, - ) - player.play_favorite.assert_called_once_with(2) - assert "Unable to select source: Failure (1)" in caplog.text + controller.play_preset_station.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to select source: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_INPUT_SOURCE: favorite.name, + }, + blocking=True, + ) + controller.play_preset_station.assert_called_once_with(1, 2) +@pytest.mark.parametrize( + ("source_name", "station"), + [ + ("HEOS Drive - Line In 1", "Line In 1"), + ("Speaker - Line In 1", "Speaker - Line In 1"), + ], +) async def test_select_input_source( - hass: HomeAssistant, config_entry, config, controller, input_sources + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + input_sources: list[MediaItem], + source_name: str, + station: str, ) -> None: """Tests selecting input source and state.""" - await setup_platform(hass, config_entry, config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - # Test proper service called - input_source = input_sources[0] + await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_INPUT_SOURCE: input_source.name, + ATTR_INPUT_SOURCE: source_name, }, blocking=True, ) - player.play_input_source.assert_called_once_with(input_source) - # Test state is matched by media id + input_source = next( + input_sources + for input_sources in input_sources + if input_sources.name == source_name + ) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) + # Update the now_playing_media to reflect play_media player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT + player.now_playing_media.station = station player.now_playing_media.media_id = const.INPUT_AUX_IN_1 - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + await controller.dispatcher.wait_send( + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") - assert state.attributes[ATTR_INPUT_SOURCE] == input_source.name + assert state is not None + assert state.attributes[ATTR_INPUT_SOURCE] == source_name -async def test_select_input_unknown( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, +async def test_select_input_unknown_raises( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Tests selecting an unknown input.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: "Unknown"}, - blocking=True, - ) - assert "Unknown source: Unknown" in caplog.text + """Tests selecting an unknown input raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + ServiceValidationError, + match=re.escape("Unknown source: Unknown"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: "Unknown"}, + blocking=True, + ) async def test_select_input_command_error( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - input_sources, + config_entry: MockConfigEntry, + controller: MockHeos, + input_sources: list[MediaItem], ) -> None: """Tests selecting an unknown input.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) input_source = input_sources[0] - player.play_input_source.side_effect = CommandFailedError(None, "Failure", 1) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_SELECT_SOURCE, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_INPUT_SOURCE: input_source.name, - }, - blocking=True, + controller.play_media.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to select source: Failure (1)"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_INPUT_SOURCE: input_source.name, + }, + blocking=True, + ) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW ) - player.play_input_source.assert_called_once_with(input_source) - assert "Unable to select source: Failure (1)" in caplog.text async def test_unload_config_entry( - hass: HomeAssistant, config_entry, config, controller + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the player is set unavailable when the config entry is unloaded.""" - await setup_platform(hass, config_entry, config) - await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_UNAVAILABLE -async def test_play_media_url( +@pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) +async def test_play_media( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: MockHeos, + media_type: MediaType, ) -> None: """Test the play media service with type url.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) url = "http://news/podcast.mp3" - # First pass completes successfully, second pass raises command error - for _ in range(2): - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, - ATTR_MEDIA_CONTENT_ID: url, - }, - blocking=True, - ) - player.play_url.assert_called_once_with(url) - player.play_url.reset_mock() - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play media: Failure (1)" in caplog.text + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: url, + }, + blocking=True, + ) + controller.play_url.assert_called_once_with(1, url) -async def test_play_media_music( +@pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) +async def test_play_media_error( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + config_entry: MockConfigEntry, + controller: MockHeos, + media_type: MediaType, ) -> None: - """Test the play media service with type music.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] + """Test the play media service with type url error raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.play_url.side_effect = CommandFailedError("", "Failure", 1) url = "http://news/podcast.mp3" - # First pass completes successfully, second pass raises command error - for _ in range(2): + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Failure (1)"), + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: url, }, blocking=True, ) - player.play_url.assert_called_once_with(url) - player.play_url.reset_mock() - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) - assert "Unable to play media: Failure (1)" in caplog.text + controller.play_url.assert_called_once_with(1, url) +@pytest.mark.parametrize( + ("content_id", "expected_index"), [("1", 1), ("Quick Select 2", 2)] +) async def test_play_media_quick_select( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - quick_selects, + config_entry: MockConfigEntry, + controller: MockHeos, + content_id: str, + expected_index: int, ) -> None: """Test the play media service with type quick_select.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - quick_select = list(quick_selects.items())[0] - index = quick_select[0] - name = quick_select[1] - # Play by index + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: str(index), + ATTR_MEDIA_CONTENT_ID: content_id, }, blocking=True, ) - player.play_quick_select.assert_called_once_with(index) - # Play by name - player.play_quick_select.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: name, - }, - blocking=True, - ) - player.play_quick_select.assert_called_once_with(index) - # Invalid name - player.play_quick_select.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "quick_select", - ATTR_MEDIA_CONTENT_ID: "Invalid", - }, - blocking=True, - ) - assert player.play_quick_select.call_count == 0 - assert "Unable to play media: Invalid quick select 'Invalid'" in caplog.text + controller.player_play_quick_select.assert_called_once_with(1, expected_index) +async def test_play_media_quick_select_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with invalid quick_select raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid quick select 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "quick_select", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.player_play_quick_select.call_count == 0 + + +@pytest.mark.parametrize( + ("enqueue", "criteria"), + [ + (None, AddCriteriaType.REPLACE_AND_PLAY), + (True, AddCriteriaType.ADD_TO_END), + ("next", AddCriteriaType.PLAY_NEXT), + ], +) async def test_play_media_playlist( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - playlists, + config_entry: MockConfigEntry, + controller: MockHeos, + playlists: list[MediaItem], + enqueue: Any, + criteria: AddCriteriaType, ) -> None: """Test the play media service with type playlist.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) playlist = playlists[0] - # Play without enqueuing + service_data = { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: playlist.name, + } + if enqueue is not None: + service_data[ATTR_MEDIA_ENQUEUE] = enqueue await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: playlist.name, - }, + service_data, blocking=True, ) - player.add_to_queue.assert_called_once_with( - playlist, const.ADD_QUEUE_REPLACE_AND_PLAY - ) - # Play with enqueuing - player.add_to_queue.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: playlist.name, - ATTR_MEDIA_ENQUEUE: True, - }, - blocking=True, - ) - player.add_to_queue.assert_called_once_with(playlist, const.ADD_QUEUE_ADD_TO_END) - # Invalid name - player.add_to_queue.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: "Invalid", - }, - blocking=True, - ) - assert player.add_to_queue.call_count == 0 - assert "Unable to play media: Invalid playlist 'Invalid'" in caplog.text + controller.play_media.assert_called_once_with(1, playlist, criteria) +async def test_play_media_playlist_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with an invalid playlist name.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid playlist 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.add_to_queue.call_count == 0 + + +@pytest.mark.parametrize( + ("content_id", "expected_index"), [("1", 1), ("Classical MPR (Classical Music)", 2)] +) async def test_play_media_favorite( hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, - favorites, + config_entry: MockConfigEntry, + controller: MockHeos, + content_id: str, + expected_index: int, ) -> None: """Test the play media service with type favorite.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - quick_select = list(favorites.items())[0] - index = quick_select[0] - name = quick_select[1].name - # Play by index + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: str(index), + ATTR_MEDIA_CONTENT_ID: content_id, }, blocking=True, ) - player.play_favorite.assert_called_once_with(index) - # Play by name - player.play_favorite.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: name, - }, - blocking=True, - ) - player.play_favorite.assert_called_once_with(index) - # Invalid name - player.play_favorite.reset_mock() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "favorite", - ATTR_MEDIA_CONTENT_ID: "Invalid", - }, - blocking=True, - ) - assert player.play_favorite.call_count == 0 - assert "Unable to play media: Invalid favorite 'Invalid'" in caplog.text + controller.play_preset_station.assert_called_once_with(1, expected_index) + + +async def test_play_media_favorite_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with an invalid favorite raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid favorite 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "favorite", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.play_preset_station.call_count == 0 async def test_play_media_invalid_type( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test the play media service with an invalid type.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_MEDIA_CONTENT_TYPE: "Other", - ATTR_MEDIA_CONTENT_ID: "", - }, - blocking=True, - ) - assert "Unable to play media: Unsupported media type 'Other'" in caplog.text - - -async def test_media_player_join_group( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test grouping of media players through the join service.""" - await setup_platform(hass, config_entry, config) - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_JOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], - }, - blocking=True, - ) - controller.create_group.assert_called_once_with( - 1, - [ - 2, - ], - ) - assert "Failed to group media_player.test_player with" not in caplog.text - - controller.create_group.side_effect = HeosError("error") - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_JOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], - }, - blocking=True, - ) - assert "Failed to group media_player.test_player with" in caplog.text - - -async def test_media_player_group_members( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test group_members attribute.""" - await setup_platform(hass, config_entry, config) - await hass.async_block_till_done() - player_entity = hass.states.get("media_player.test_player") - assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ - "media_player.test_player", - "media_player.test_player_2", - ] - controller.get_groups.assert_called_once() - assert "Unable to get HEOS group info" not in caplog.text - - -async def test_media_player_group_members_error( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error in HEOS API.""" - controller.get_groups.side_effect = HeosError("error") - await setup_platform(hass, config_entry, config) - await hass.async_block_till_done() - assert "Unable to get HEOS group info" in caplog.text - player_entity = hass.states.get("media_player.test_player") - assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [] - - -async def test_media_player_unjoin_group( - hass: HomeAssistant, - config_entry, - config, - controller, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test ungrouping of media players through the join service.""" - await setup_platform(hass, config_entry, config) - player = controller.players[1] - - player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, - player.player_id, - const.EVENT_PLAYER_STATE_CHANGED, - ) - await hass.async_block_till_done() - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_UNJOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - }, - blocking=True, - ) - controller.create_group.assert_called_once_with(1, []) - assert "Failed to ungroup media_player.test_player" not in caplog.text - - controller.create_group.side_effect = HeosError("error") - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_UNJOIN, - { - ATTR_ENTITY_ID: "media_player.test_player", - }, - blocking=True, - ) - assert "Failed to ungroup media_player.test_player" in caplog.text - - -async def test_media_player_group_fails_when_entity_removed( - hass: HomeAssistant, - config_entry, - config, - controller, - entity_registry: er.EntityRegistry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test grouping fails when entity removed.""" - await setup_platform(hass, config_entry, config) - - # Remove one of the players - entity_registry.async_remove("media_player.test_player_2") - - # Attempt to group + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) with pytest.raises( HomeAssistantError, - match="The group member media_player.test_player_2 could not be resolved to a HEOS player.", + match=re.escape("Unable to play media: Unsupported media type 'Other'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "Other", + ATTR_MEDIA_CONTENT_ID: "", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("members", "expected"), + [ + (["media_player.test_player_2"], [1, 2]), + (["media_player.test_player_2", "media_player.test_player"], [1, 2]), + (["media_player.test_player"], [1]), + ], +) +async def test_media_player_join_group( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + members: list[str], + expected: tuple[int, list[int]], +) -> None: + """Test grouping of media players through the join service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: members, + }, + blocking=True, + ) + controller.set_group.assert_called_once_with(expected) + + +async def test_media_player_join_group_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test grouping of media players through the join service raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to join players: error"), ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -1082,4 +1325,143 @@ async def test_media_player_group_fails_when_entity_removed( }, blocking=True, ) - controller.create_group.assert_not_called() + + +async def test_media_player_group_members( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test group_members attribute.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None + assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + controller.get_groups.assert_called_once() + assert "Unable to get HEOS group info" not in caplog.text + + +async def test_media_player_group_members_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error in HEOS API.""" + controller.mock_set_groups({}) + controller.get_groups.side_effect = HeosError("error") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert "Unable to retrieve groups" in caplog.text + player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None + assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None + + +@pytest.mark.parametrize( + ("entity_id", "expected_args"), + [("media_player.test_player", [1]), ("media_player.test_player_2", [1])], +) +async def test_media_player_unjoin_group( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + entity_id: str, + expected_args: list[int], +) -> None: + """Test ungrouping of media players through the unjoin service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + controller.set_group.assert_called_once_with(expected_args) + + +async def test_media_player_unjoin_group_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test ungrouping of media players through the unjoin service error raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to unjoin player: error"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + ) + + +async def test_media_player_group_fails_when_entity_removed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + entity_registry: er.EntityRegistry, +) -> None: + """Test grouping fails when entity removed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + # Remove one of the players + entity_registry.async_remove("media_player.test_player_2") + + # Attempt to group + with pytest.raises(ServiceValidationError, match="was not found"): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + controller.set_group.assert_not_called() + + +async def test_media_player_group_fails_wrong_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + entity_registry: er.EntityRegistry, +) -> None: + """Test grouping fails when trying to join from the wrong integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + # Create an entity in another integration + entry = entity_registry.async_get_or_create( + "media_player", "Other", "test_player_2" + ) + + # Attempt to group + with pytest.raises( + ServiceValidationError, match="is not a HEOS media player entity" + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: [entry.entity_id], + }, + blocking=True, + ) + controller.set_group.assert_not_called() diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index b1cffe0891e..151571ceb50 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -11,22 +11,19 @@ from homeassistant.components.heos.const import ( SERVICE_SIGN_OUT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import MockHeos from tests.common import MockConfigEntry -async def setup_component(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Set up the component for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - -async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: +async def test_sign_in( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: """Test the sign-in service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call( DOMAIN, @@ -38,64 +35,55 @@ async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: controller.sign_in.assert_called_once_with("test@test.com", "password") -async def test_sign_in_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) - - assert controller.sign_in.call_count == 0 - assert "Unable to sign in because HEOS is not connected" in caplog.text - - async def test_sign_in_failed( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.sign_in.side_effect = CommandFailedError("", "Invalid credentials", 6) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, + controller.sign_in.side_effect = CommandAuthenticationError( + "", "Invalid credentials", 6 ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) + controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Sign in failed: Invalid credentials (6)" in caplog.text async def test_sign_in_unknown_error( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error for failure.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.sign_in.side_effect = HeosError() - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Unable to sign in" in caplog.text -async def test_sign_in_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: +async def test_sign_in_not_loaded_raises( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test the sign-in service when entry not loaded raises exception.""" - await setup_component(hass, config_entry) - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): await hass.services.async_call( @@ -106,45 +94,39 @@ async def test_sign_in_not_loaded_raises(hass: HomeAssistant, config_entry) -> N ) -async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: +async def test_sign_out( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: """Test the sign-out service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 -async def test_sign_out_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture +async def test_sign_out_not_loaded_raises( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test the sign-out service.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) - - assert controller.sign_out.call_count == 0 - assert "Unable to sign out because HEOS is not connected" in caplog.text - - -async def test_sign_out_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: """Test the sign-out service when entry not loaded raises exception.""" - await setup_component(hass, config_entry) - await hass.config_entries.async_unload(config_entry.entry_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry.entry_id) with pytest.raises(HomeAssistantError, match="The HEOS integration is not loaded"): await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) async def test_sign_out_unknown_error( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" - await setup_component(hass, config_entry) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) controller.sign_out.side_effect = HeosError() - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + with pytest.raises(HomeAssistantError): + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 - assert "Unable to sign out" in caplog.text diff --git a/tests/components/history/conftest.py b/tests/components/history/conftest.py index dd10fccccdc..8269d3319cb 100644 --- a/tests/components/history/conftest.py +++ b/tests/components/history/conftest.py @@ -8,12 +8,12 @@ from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 3b4b02a877e..f1890073567 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -16,7 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 717840c6b05..01b49ad5575 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STAT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 301de387c80..c9577e20fcf 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,12 +1,14 @@ """The tests the History component websocket_api.""" +from collections.abc import Generator + import pytest from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( async_recorder_block_till_done, @@ -17,9 +19,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 3039612d1a0..721e540b04d 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components.history_stats.const import ( CONF_END, CONF_START, @@ -27,12 +27,11 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 2ac8c851e1b..49cbc89ba41 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -1,18 +1,41 @@ """Test fixtures for home_connect.""" -from collections.abc import Awaitable, Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import copy import time -from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfCommands, + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfOptions, + ArrayOfPrograms, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + HomeAppliance, + Option, + Program, + ProgramDefinition, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.program import EnumerateProgram import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect import update_all_devices from homeassistant.components.home_connect.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,12 +43,18 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture -MOCK_APPLIANCES_PROPERTIES = { - x["name"]: x - for x in load_json_object_fixture("home_connect/appliances.json")["data"][ - "homeappliances" - ] -} +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] +) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -102,32 +131,23 @@ def platforms() -> list[Platform]: return [] -async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry): - """Add kwarg to disable throttle.""" - await update_all_devices(hass, config_entry, no_throttle=True) - - -@pytest.fixture(name="bypass_throttle") -def mock_bypass_throttle() -> Generator[None]: - """Fixture to bypass the throttle decorator in __init__.""" - with patch( - "homeassistant.components.home_connect.update_all_devices", - side_effect=bypass_throttle, - ): - yield - - @pytest.fixture(name="integration_setup") async def mock_integration_setup( hass: HomeAssistant, platforms: list[Platform], config_entry: MockConfigEntry, -) -> Callable[[], Awaitable[bool]]: +) -> Callable[[MagicMock], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - async def run() -> bool: - with patch("homeassistant.components.home_connect.PLATFORMS", platforms): + async def run(client: MagicMock) -> bool: + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch( + "homeassistant.components.home_connect.HomeConnectClient" + ) as client_mock, + ): + client_mock.return_value = client result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return result @@ -135,125 +155,341 @@ async def mock_integration_setup( return run -@pytest.fixture(name="get_appliances") -def mock_get_appliances() -> Generator[MagicMock]: - """Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" - with patch( - "homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances", - ) as mock: - yield mock +def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: + """Get specific appliance side effect.""" + for appliance in copy.deepcopy(MOCK_APPLIANCES).homeappliances: + if appliance.ha_id == ha_id: + return appliance + raise HomeConnectApiError("error.key", "error description") -@pytest.fixture(name="appliance") -def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: +def _get_set_program_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey +): + """Set program side effect.""" + + async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=str(kwargs["program_key"]), + ), + *[ + Event( + key=(option_event := EventKey(option.key)), + raw_key=option_event.value, + timestamp=0, + level="", + handling="", + value=str(option.key), + ) + for option in cast( + list[Option], kwargs.get("options", []) + ) + ], + ] + ), + ), + ] + ) + + return set_program_side_effect + + +def _get_set_setting_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], +): + """Set settings side effect.""" + + async def set_settings_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["setting_key"]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + return set_settings_side_effect + + +def _get_set_program_options_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], +): + """Set programs side effect.""" + + async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey(option.key), + raw_key=option.key.value, + timestamp=0, + level="", + handling="", + value=option.value, + ) + for option in ( + cast(ArrayOfOptions, kwargs["array_of_options"]).options + if "array_of_options" in kwargs + else [ + Option( + kwargs["option_key"], + kwargs["value"], + unit=kwargs["unit"], + ) + ] + ) + ] + ), + ), + ] + ) + + return set_program_options_side_effect + + +async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" + appliance_type = next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfPrograms( + [ + EnumerateProgram.from_dict(program) + for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] + ], + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + ) + + +async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + +async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): + """Get setting.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id: + settings = MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + for setting_dict in cast(list[dict], settings["settings"]): + if setting_dict["key"] == setting_key: + return GetSetting.from_dict(setting_dict) + raise HomeConnectApiError("error.key", "error description") + + +async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) + raise HomeConnectApiError("error.key", "error description") + + +@pytest.fixture(name="client") +def mock_client(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect.""" + + mock = MagicMock( + autospec=HomeConnectClient, + ) + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def add_events(events: list[EventMessage]) -> None: + await event_queue.put(events) + + mock.add_events = add_events + + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + mock.get_specific_appliance = AsyncMock( + side_effect=_get_specific_appliance_side_effect + ) + mock.stream_all_events = stream_all_events + mock.start_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + ) + ) + mock.set_selected_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM + ), + ) + mock.stop_program = AsyncMock() + mock.set_active_program_option = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_active_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_selected_program_option = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_selected_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_setting = AsyncMock( + side_effect=_get_set_setting_side_effect(event_queue), + ) + mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) + mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) + mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) + mock.get_available_commands = AsyncMock( + side_effect=_get_available_commands_side_effect + ) + mock.put_command = AsyncMock() + mock.get_available_program = AsyncMock( + return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) + ) + mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.set_active_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) + mock.set_selected_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) + + mock.side_effect = mock + return mock + + +@pytest.fixture(name="client_with_exception") +def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect that raise exceptions.""" + mock = MagicMock( + autospec=HomeConnectClient, + ) + + exception = HomeConnectError() + if hasattr(request, "param") and request.param: + exception = request.param + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + mock.stream_all_events = stream_all_events + + mock.start_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.set_selected_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_active_program_options = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_options = AsyncMock(side_effect=exception) + mock.set_setting = AsyncMock(side_effect=exception) + mock.get_settings = AsyncMock(side_effect=exception) + mock.get_setting = AsyncMock(side_effect=exception) + mock.get_status = AsyncMock(side_effect=exception) + mock.get_all_programs = AsyncMock(side_effect=exception) + mock.get_available_commands = AsyncMock(side_effect=exception) + mock.put_command = AsyncMock(side_effect=exception) + mock.get_available_program = AsyncMock(side_effect=exception) + mock.get_active_program_options = AsyncMock(side_effect=exception) + mock.get_selected_program_options = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) + + return mock + + +@pytest.fixture(name="appliance_ha_id") +def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: app = request.param - - mock = MagicMock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.return_value = {} - mock.get_programs_available.return_value = [] - mock.get_status.return_value = {} - mock.get_settings.return_value = {} - - return mock - - -@pytest.fixture(name="problematic_appliance") -def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: - """Fixture to mock a problematic Appliance.""" - app = "Washer" - if hasattr(request, "param") and request.param: - app = request.param - - mock = Mock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.side_effect = HomeConnectError - mock.get_programs_active.side_effect = HomeConnectError - mock.get_programs_available.side_effect = HomeConnectError - mock.start_program.side_effect = HomeConnectError - mock.select_program.side_effect = HomeConnectError - mock.pause_program.side_effect = HomeConnectError - mock.stop_program.side_effect = HomeConnectError - mock.set_options_active_program.side_effect = HomeConnectError - mock.set_options_selected_program.side_effect = HomeConnectError - mock.get_status.side_effect = HomeConnectError - mock.get_settings.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.execute_command.side_effect = HomeConnectError - - return mock - - -def get_all_appliances(): - """Return a list of `HomeConnectAppliance` instances for all appliances.""" - - appliances = {} - - data = load_json_object_fixture("home_connect/appliances.json").get("data") - programs_active = load_json_object_fixture("home_connect/programs-active.json") - programs_available = load_json_object_fixture( - "home_connect/programs-available.json" - ) - - def listen_callback(mock, callback): - callback["callback"](mock) - - for home_appliance in data["homeappliances"]: - api_status = load_json_object_fixture("home_connect/status.json") - api_settings = load_json_object_fixture("home_connect/settings.json") - - ha_id = home_appliance["haId"] - ha_type = home_appliance["type"] - - appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance) - appliance.name = home_appliance["name"] - appliance.listen_events.side_effect = ( - lambda app=appliance, **x: listen_callback(app, x) - ) - appliance.get_programs_active.return_value = programs_active.get( - ha_type, {} - ).get("data", {}) - appliance.get_programs_available.return_value = [ - program["key"] - for program in programs_available.get(ha_type, {}) - .get("data", {}) - .get("programs", []) - ] - appliance.get_status.return_value = HomeConnectAppliance.json2dict( - api_status.get("data", {}).get("status", []) - ) - appliance.get_settings.return_value = HomeConnectAppliance.json2dict( - api_settings.get(ha_type, {}).get("data", {}).get("settings", []) - ) - setattr(appliance, "status", {}) - appliance.status.update(appliance.get_status.return_value) - appliance.status.update(appliance.get_settings.return_value) - appliance.set_setting.side_effect = ( - lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}}) - ) - appliance.start_program.side_effect = ( - lambda x, appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {"value": x}} - ) - ) - appliance.stop_program.side_effect = ( - lambda appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {}} - ) - ) - - appliances[ha_id] = appliance - - return list(appliances.values()) + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.type == app: + return appliance.ha_id + raise ValueError(f"Appliance {app} not found") diff --git a/tests/components/home_connect/fixtures/available_commands.json b/tests/components/home_connect/fixtures/available_commands.json new file mode 100644 index 00000000000..e4ed6c21b7c --- /dev/null +++ b/tests/components/home_connect/fixtures/available_commands.json @@ -0,0 +1,142 @@ +{ + "Cooktop": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Hood": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Oven": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + }, + { + "key": "BSH.Common.Command.PartlyOpenDoor", + "name": "Partly open door" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "CleaningRobot": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dishwasher": { + "commands": [ + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Washer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "WasherDryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Freezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "FridgeFreezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "Refrigerator": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + } +} diff --git a/tests/components/home_connect/fixtures/programs-available.json b/tests/components/home_connect/fixtures/programs.json similarity index 100% rename from tests/components/home_connect/fixtures/programs-available.json rename to tests/components/home_connect/fixtures/programs.json diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 1b9bec57276..bd1bea18365 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -2,6 +2,11 @@ "Dishwasher": { "data": { "settings": [ + { + "key": "BSH.Common.Setting.ChildLock", + "value": false, + "type": "Boolean" + }, { "key": "BSH.Common.Setting.AmbientLightEnabled", "value": true, @@ -26,7 +31,13 @@ { "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", - "type": "BSH.Common.EnumType.PowerState" + "type": "BSH.Common.EnumType.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.On", + "BSH.Common.EnumType.PowerState.Off" + ] + } }, { "key": "BSH.Common.Setting.ChildLock", @@ -57,9 +68,16 @@ "type": "Double" }, { - "key": "BSH.Common.Setting.ColorTemperature", + "key": "Cooking.Hood.Setting.ColorTemperature", "value": "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", - "type": "BSH.Common.EnumType.ColorTemperature" + "type": "BSH.Common.EnumType.ColorTemperature", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.cold" + ] + } }, { "key": "BSH.Common.Setting.AmbientLightEnabled", @@ -92,6 +110,11 @@ "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", "type": "BSH.Common.EnumType.PowerState" + }, + { + "key": "BSH.Common.Setting.AlarmClock", + "value": 0, + "type": "Integer" } ] } @@ -108,6 +131,11 @@ "key": "BSH.Common.Setting.ChildLock", "value": false, "type": "Boolean" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 0, + "type": "Integer" } ] } @@ -154,6 +182,12 @@ "max": 100, "access": "readWrite" } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + "value": 8, + "unit": "°C", + "type": "Double" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3131eac52f..28f45ce97ba 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,255 +2,209 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/00', + 'ha_id': 'BOSCH-000000000-000000000000', + 'name': 'DNE', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'DNE', + 'vib': 'HCS000000', }), 'BOSCH-HCS000000-D00000000001': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/01', + 'ha_id': 'BOSCH-HCS000000-D00000000001', + 'name': 'WasherDryer', 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'WasherDryer', + 'vib': 'HCS000001', }), 'BOSCH-HCS000000-D00000000002': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/02', + 'ha_id': 'BOSCH-HCS000000-D00000000002', + 'name': 'Refrigerator', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Refrigerator', + 'vib': 'HCS000002', }), 'BOSCH-HCS000000-D00000000003': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/03', + 'ha_id': 'BOSCH-HCS000000-D00000000003', + 'name': 'Freezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Freezer', + 'vib': 'HCS000003', }), 'BOSCH-HCS000000-D00000000004': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/04', + 'ha_id': 'BOSCH-HCS000000-D00000000004', + 'name': 'Hood', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'Cooking.Common.Setting.Lighting': True, + 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperature': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', + 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hood', + 'vib': 'HCS000004', }), 'BOSCH-HCS000000-D00000000005': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-D00000000005', + 'name': 'Hob', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', }), 'BOSCH-HCS000000-D00000000006': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': 'BOSCH-HCS000000-D00000000006', + 'name': 'CookProcessor', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS01OVN1/03', + 'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245', + 'name': 'Oven', 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AlarmClock': 0, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Oven', + 'vib': 'HCS01OVN1', }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS04DYR1/03', + 'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A', + 'name': 'Dryer', 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Mix', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dryer', + 'vib': 'HCS04DYR1', }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS06COM1/03', + 'ha_id': 'BOSCH-HCS06COM1-D70390681C2C', + 'name': 'CoffeeMaker', 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -259,26 +213,24 @@ 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CoffeeMaker', + 'vib': 'HCS06COM1', }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -286,51 +238,30 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS03WCH1/03', + 'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794', + 'name': 'Washer', 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -338,97 +269,56 @@ 'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.Wool', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Washer', + 'vib': 'HCS03WCH1', }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS05FRF1/03', + 'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D', + 'name': 'FridgeFreezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), + 'settings': dict({ + 'Refrigeration.Common.Setting.Dispenser.Enabled': False, + 'Refrigeration.Common.Setting.Light.External.Brightness': 70, + 'Refrigeration.Common.Setting.Light.External.Power': True, + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'FridgeFreezer', + 'vib': 'HCS05FRF1', }), }) # --- # name: test_async_get_device_diagnostics dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -436,47 +326,22 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }) # --- diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr new file mode 100644 index 00000000000..709621aaefb --- /dev/null +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_set_program_and_options[service_call0-set_selected_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 1800, + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call1-start_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal', + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call2-set_active_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent', + }), + ]), + }), + }), + ) +# --- +# name: test_set_program_and_options[service_call3-set_selected_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 35, + }), + ]), + }), + }), + ) +# --- diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index b564b003af6..a06e386b84f 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,32 +1,37 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAPI +from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType +from aiohomeconnect.model.error import HomeConnectApiError import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry @pytest.fixture @@ -35,123 +40,366 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test binary sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_status_original_mock = client.get_status + + def get_status_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return get_status_original_mock.return_value + + client.get_status = AsyncMock(side_effect=get_status_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_status = get_status_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +async def test_binary_sensors_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if binary sensor entities availability are based on the appliance connection state.""" + entity_ids = [ + "binary_sensor.washer_door", + "binary_sensor.washer_remote_control", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize( - ("state", "expected"), + ("value", "expected"), [ (BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_OPEN, "on"), - ("", "unavailable"), + ("", STATE_UNKNOWN), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_door_states( + appliance_ha_id: str, expected: str, - state: str, + value: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Tests for Appliance door states.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": state}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), [ + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + False, + STATE_OFF, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + True, + STATE_ON, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + "", + STATE_UNKNOWN, + "Washer", + ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_CLOSED, STATE_OFF, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_OPEN, STATE_ON, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, "", - STATE_UNAVAILABLE, + STATE_UNKNOWN, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_bianry_sensors_fridge_door_states( +async def test_binary_sensors_functionality( entity_id: str, - status_key: str, + event_key: EventKey, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) +async def test_connected_sensor_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if the connected binary sensor reports the right values.""" + entity_id = "binary_sensor.washer_connectivity" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state(entity_id, STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_OFF) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_ON) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( @@ -189,8 +437,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py new file mode 100644 index 00000000000..5af7e40ca43 --- /dev/null +++ b/tests/components/home_connect/test_button.py @@ -0,0 +1,315 @@ +"""Tests for home_connect button entities.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model.command import Command +from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +async def test_buttons( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test button entities.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_available_commands_original_mock = client.get_available_commands + get_available_programs_mock = client.get_available_programs + + async def get_available_commands_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_commands_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_available_commands = get_available_commands_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +async def test_button_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_ids = [ + "button.washer_pause_program", + "button.washer_stop_program", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method_call", "expected_kwargs"), + [ + ( + "button.washer_pause_program", + "put_command", + {"command_key": CommandKey.BSH_COMMON_PAUSE_PROGRAM, "value": True}, + ), + ("button.washer_stop_program", "stop_program", {}), + ], +) +async def test_button_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_id: str, + method_call: str, + expected_kwargs: dict[str, Any], + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + + +async def test_command_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_pause_program" + + client_with_exception.get_available_commands = AsyncMock( + return_value=ArrayOfCommands( + [ + Command( + CommandKey.BSH_COMMON_PAUSE_PROGRAM, + "Pause Program", + ) + ] + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*executing.*command"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_stop_program_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_stop_program" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*stop.*program"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 80f53e20b39..c35678e4e5f 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Home Connect config flow.""" +from collections.abc import Awaitable, Callable from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest from homeassistant import config_entries, setup @@ -10,15 +12,12 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -80,3 +79,72 @@ async def test_full_flow( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_prevent_multiple_config_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we only allow one config entry.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "home_connect", context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth flow.""" + result = await config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + _client = await hass_client_no_auth() + resp = await _client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.home_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py new file mode 100644 index 00000000000..3dd9ffbe7c1 --- /dev/null +++ b/tests/components/home_connect/test_coordinator.py @@ -0,0 +1,381 @@ +"""Test for Home Connect coordinator.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_EVENT_PRESENT_STATE_PRESENT, + BSH_POWER_OFF, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntryState +from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.core import ( + Event as HassEvent, + EventStateReportedData, + HomeAssistant, + callback, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] + + +async def test_coordinator_update( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the coordinator can update.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_update_failing_get_appliances( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "mock_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_coordinator_update_failing( + mock_method: str, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that although is not possible to get settings and status, the config entry is loaded. + + This is for cases where some appliances are reachable and some are not in the same configuration entry. + """ + setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + getattr(client, mock_method).assert_called() + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("event_type", "event_key", "event_value", "entity_id"), + [ + ( + EventType.STATUS, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "sensor.dishwasher_door", + ), + ( + EventType.NOTIFY, + EventKey.BSH_COMMON_SETTING_POWER_STATE, + BSH_POWER_OFF, + "switch.dishwasher_power", + ), + ( + EventType.EVENT, + EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "sensor.dishwasher_salt_nearly_empty", + ), + ], +) +async def test_event_listener( + event_type: EventType, + event_key: EventKey, + event_value: str, + entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the event listener works.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + event_message = EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + new_state = hass.states.get(entity_id) + assert new_state + assert new_state.state != state.state + + # Following, we are gonna check that the listeners are clean up correctly + new_entity_id = entity_id + "_new" + listener = MagicMock() + + @callback + def listener_callback(event: HassEvent[EventStateReportedData]) -> None: + listener(event.data["entity_id"]) + + @callback + def event_filter(_: EventStateReportedData) -> bool: + return True + + hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + + entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + await client.add_events([event_message]) + await hass.async_block_till_done() + + # Because the entity's id has been updated, the entity has been unloaded + # and the listener has been removed, and the new entity adds a new listener, + # so the only entity that should report states is the one with the new entity id + listener.assert_called_once_with(new_entity_id) + + +async def tests_receive_setting_and_status_for_first_time_at_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is capable of receiving settings and status for the first time.""" + client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) + client.get_status = AsyncMock(return_value=ArrayOfStatus([])) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL, + raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert len(config_entry._background_tasks) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_event_listener_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the configuration entry is reloaded when the event stream raises an API error.""" + client_with_exception.stream_all_events = MagicMock( + side_effect=HomeConnectApiError("error.key", "error description") + ) + + with patch.object( + ConfigEntries, + "async_schedule_reload", + ) as mock_schedule_reload: + await integration_setup(client_with_exception) + await hass.async_block_till_done() + + client_with_exception.stream_all_events.assert_called_once() + mock_schedule_reload.assert_called_once_with(config_entry.entry_id) + assert not config_entry._background_tasks + + +@pytest.mark.parametrize( + "exception", + [HomeConnectRequestError(), EventStreamInterruptedError()], +) +@pytest.mark.parametrize( + ( + "entity_id", + "initial_state", + "status_key", + "status_value", + "after_refresh_expected_state", + "event_key", + "event_value", + "after_event_expected_state", + ), + [ + ( + "sensor.washer_door", + "closed", + StatusKey.BSH_COMMON_DOOR_STATE, + BSH_DOOR_STATE_LOCKED, + "locked", + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "open", + ), + ], +) +@patch( + "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 +) +async def test_event_listener_resilience( + entity_id: str, + initial_state: str, + status_key: StatusKey, + status_value: Any, + after_refresh_expected_state: str, + event_key: EventKey, + event_value: Any, + after_event_expected_state: str, + exception: HomeConnectError, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is resilient to interruptions.""" + future = hass.loop.create_future() + + async def stream_exception(): + yield await future + + client.stream_all_events = MagicMock( + side_effect=[stream_exception(), client.stream_all_events()] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(config_entry._background_tasks) == 1 + + assert hass.states.is_state(entity_id, initial_state) + + client.get_status.return_value = ArrayOfStatus( + [Status(key=status_key, raw_key=status_key.value, value=status_value)], + ) + await hass.async_block_till_done() + future.set_exception(exception) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert client.stream_all_events.call_count == 2 + assert hass.states.is_state(entity_id, after_refresh_expected_state) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, after_event_expected_state) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index f2db6e2b67a..ab6823411dc 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,11 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError -import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( @@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import get_all_appliances - from tests.common import MockConfigEntry -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_device_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device = device_registry.async_get_or_create( @@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot - - -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "Random-Device-ID")}, - ) - - with pytest.raises(ValueError): - await async_get_device_diagnostics(hass, config_entry, device) - - -@pytest.mark.parametrize( - ("api_error", "expected_connection_status"), - [ - (HomeConnectError(), "unknown"), - ( - HomeConnectError( - { - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", - } - ), - "offline", - ), - ], -) -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_api_error( - api_error: HomeConnectError, - expected_connection_status: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - appliance.get_programs_available.side_effect = api_error - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, - ) - - diagnostics = await async_get_device_diagnostics(hass, config_entry, device) - assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py new file mode 100644 index 00000000000..bad02888dbf --- /dev/null +++ b/tests/components/home_connect/test_entity.py @@ -0,0 +1,493 @@ +"""Tests for Home Connect entity base classes.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfPrograms, + Event, + EventKey, + EventMessage, + EventType, + Option, + OptionKey, + Program, + ProgramDefinition, + ProgramKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + EnumerateProgram, + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "option_entity_id", + "options_state_stage_1", + "options_availability_stage_2", + "option_without_default", + "option_without_constraints", + ), + [ + ( + "Dishwasher", + { + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: "switch.dishwasher_silence_on_demand", + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: "switch.dishwasher_eco_dry", + }, + [(STATE_ON, True), (STATE_OFF, False), (None, None)], + [False, True, True], + ( + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + "switch.dishwasher_hygiene", + ), + (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + option_entity_id: dict[OptionKey, str], + options_state_stage_1: list[tuple[str, bool | None]], + options_availability_stage_2: list[bool], + option_without_default: tuple[OptionKey, str], + option_without_constraints: tuple[OptionKey, str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + original_get_all_programs_mock = client.get_all_programs.side_effect + options_values = [ + Option( + option_key, + value, + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ] + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + if ha_id != appliance_ha_id: + return await original_get_all_programs_mock(ha_id) + + array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) + return ArrayOfPrograms( + **( + { + "programs": array_of_programs.programs, + array_of_programs_program_arg: Program( + array_of_programs.programs[0].key, options=options_values + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id, (state, _) in zip( + option_entity_id.values(), options_state_stage_1, strict=True + ): + if state is not None: + assert hass.states.is_state(entity_id, state) + else: + assert not hass.states.get(entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + *[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, available in zip( + option_entity_id.keys(), + options_availability_stage_2, + strict=True, + ) + if available + ], + ProgramDefinitionOption( + option_without_default[0], + "Boolean", + constraints=ProgramDefinitionConstraints(), + ), + ProgramDefinitionOption( + option_without_constraints[0], + "Boolean", + ), + ], + ) + ) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + # Verify default values + # Every time the program is updated, the available options should use the default value if existing + for entity_id, available in zip( + option_entity_id.values(), options_availability_stage_2, strict=True + ): + assert hass.states.is_state( + entity_id, STATE_OFF if available else STATE_UNAVAILABLE + ) + for _, entity_id in (option_without_default, option_without_constraints): + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + + +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +async def test_no_options_retrieval_on_unknown_program( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that no options are retrieved when the program is unknown.""" + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + return ArrayOfPrograms( + **( + { + "programs": [ + EnumerateProgram(ProgramKey.UNKNOWN, "unknown program") + ], + array_of_programs_program_arg: Program( + ProgramKey.UNKNOWN, options=[] + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_available_program.call_count == 0 + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert client.get_available_program.call_count == 0 + + +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "option_key", "option_entity_id"), + [ + ( + "Dishwasher", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "switch.dishwasher_half_load", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval_after_appliance_connection( + event_key: EventKey, + appliance_ha_id: str, + option_key: OptionKey, + option_entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + array_of_home_appliances = client.get_home_appliances.return_value + + async def get_home_appliances_with_options_mock() -> ArrayOfHomeAppliances: + return ArrayOfHomeAppliances( + [ + appliance + for appliance in array_of_home_appliances.homeappliances + if appliance.ha_id != appliance_ha_id + ] + ) + + client.get_home_appliances = AsyncMock( + side_effect=get_home_appliances_with_options_mock + ) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(option_entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + raw_key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED.value, + timestamp=0, + level="", + handling="", + value="", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert not hass.states.get(option_entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ), + ], + ) + ) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(option_entity_id) + + +@pytest.mark.parametrize( + ( + "set_active_program_option_side_effect", + "set_selected_program_option_side_effect", + ), + [ + ( + ActiveProgramNotSetError("error.key"), + SelectedProgramNotSetError("error.key"), + ), + ( + HomeConnectError(), + None, + ), + ( + ActiveProgramNotSetError("error.key"), + HomeConnectError(), + ), + ], +) +async def test_option_entity_functionality_exception( + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the option entity handles exceptions correctly.""" + entity_id = "switch.washer_i_dos_1_active" + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + if set_active_program_option_side_effect: + client.set_active_program_option = AsyncMock( + side_effect=set_active_program_option_side_effect + ) + if set_selected_program_option_side_effect: + client.set_selected_program_option = AsyncMock( + side_effect=set_selected_program_option_side_effect + ) + + with pytest.raises(HomeAssistantError, match=r"Error.*setting.*option.*"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 69601efb42d..6e4e428bf6a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,28 +1,21 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +from aiohomeconnect.const import OAUTH2_TOKEN +from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError import pytest -from requests import HTTPError import requests_mock +import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import ( - SCAN_INTERVAL, - bsh_key_to_translation_key, -) -from homeassistant.components.home_connect.const import ( - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, - BSH_POWER_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, - COOKING_LIGHTING, - DOMAIN, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -31,6 +24,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -39,21 +33,21 @@ from .conftest import ( FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, - get_all_appliances, ) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -SERVICE_KV_CALL_PARAMS = [ +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ { "domain": DOMAIN, "service": "set_option_active", "service_data": { "device_id": "DEVICE_ID", - "key": "", - "value": "", - "unit": "", + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", }, "blocking": True, }, @@ -62,18 +56,22 @@ SERVICE_KV_CALL_PARAMS = [ "service": "set_option_selected", "service_data": { "device_id": "DEVICE_ID", - "key": "", - "value": "", + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", }, "blocking": True, }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, { "domain": DOMAIN, "service": "change_setting", "service_data": { "device_id": "DEVICE_ID", - "key": "", - "value": "", + "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, + "value": True, }, "blocking": True, }, @@ -105,9 +103,9 @@ SERVICE_PROGRAM_CALL_PARAMS = [ "service": "select_program", "service_data": { "device_id": "DEVICE_ID", - "program": "", - "key": "", - "value": "", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", }, "blocking": True, }, @@ -116,38 +114,92 @@ SERVICE_PROGRAM_CALL_PARAMS = [ "service": "start_program", "service_data": { "device_id": "DEVICE_ID", - "program": "", - "key": "", - "value": "", - "unit": "C", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", }, "blocking": True, }, ] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_options_active_program", - "set_option_selected": "set_options_selected_program", + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "execute_command", - "resume_program": "execute_command", - "select_program": "select_program", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", "start_program": "start_program", } +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} -@pytest.mark.usefixtures("bypass_throttle") -async def test_api_setup( + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": 1800, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + + +async def test_entry_setup( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test setup and unload.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -156,72 +208,60 @@ async def test_api_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_update_throttle( - appliance: Mock, - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, -) -> None: - """Test to check Throttle functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - get_appliances_call_count = get_appliances.call_count - - # First re-load after 1 minute is not blocked. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds + 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - # Second re-load is blocked by Throttle. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds - 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - -@pytest.mark.usefixtures("bypass_throttle") async def test_exception_handling( - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@pytest.mark.usefixtures("bypass_throttle") +@respx.mock async def test_token_refresh_success( - integration_setup: Callable[[], Awaitable[bool]], + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, requests_mock: requests_mock.Mocker, setup_credentials: None, + client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) - requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}}) - aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - assert await integration_setup() + appliances = client.get_home_appliances.return_value + + async def mock_get_home_appliances(): + await client._auth.async_get_access_token() + return appliances + + client.get_home_appliances.return_value = None + client.get_home_appliances.side_effect = mock_get_home_appliances + + def init_side_effect(auth) -> MagicMock: + client._auth = auth + return client + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, + ): + client_mock.side_effect = MagicMock(side_effect=init_side_effect) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED # Verify token request @@ -240,45 +280,52 @@ async def test_token_refresh_success( ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_http_error( +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (HomeConnectError(), ConfigEntryState.SETUP_RETRY), + (UnauthorizedError("error.key"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_client_error( + exception: HomeConnectError, + expected_state: ConfigEntryState, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: - """Test HTTP errors during setup integration.""" - get_appliances.side_effect = HTTPError(response=MagicMock()) + """Test client errors during setup integration.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = exception assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - assert get_appliances.call_count == 1 + assert not await integration_setup(client_with_exception) + assert config_entry.state == expected_state + assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_services( - service_call: list[dict[str, Any]], +async def test_key_value_services( + service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, ) -> None: """Create and test services.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_name = service_call["service"] @@ -286,35 +333,224 @@ async def test_services( await hass.services.async_call(**service_call) await hass.async_block_till_done() assert ( - getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count - == 1 + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 ) +@pytest.mark.parametrize( + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + issue_id: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +async def test_required_program_or_at_least_an_option( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + "Test that the set_program_and_options does raise an exception if no program nor options are set." + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + with pytest.raises( + ServiceValidationError, + ): + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + { + "device_id": device_entry.id, + "affects_to": "selected_program", + }, + True, + ) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_services_exception( - service_call: list[dict[str, Any]], +async def test_services_exception_device_id( + service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, + appliance_ha_id: str, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, problematic_appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -323,35 +559,89 @@ async def test_services_exception( await hass.services.async_call(**service_call) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) + + async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: Mock, + appliance_ha_id: str, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -360,34 +650,39 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) test_entities = [ ( SENSOR_DOMAIN, "Operation State", - BSH_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE, ), ( SWITCH_DOMAIN, "ChildLock", - BSH_CHILD_LOCK_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, ), ( SWITCH_DOMAIN, "Power", - BSH_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE, ), ( BINARY_SENSOR_DOMAIN, "Remote Start", - BSH_REMOTE_START_ALLOWANCE_STATE, + StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, ), ( LIGHT_DOMAIN, "Light", - COOKING_LIGHTING, + SettingKey.COOKING_COMMON_LIGHTING, + ), + ( # An already migrated entity + SWITCH_DOMAIN, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, ), ] @@ -395,7 +690,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance.haId}-{old_unique_id_suffix}", + f"{appliance_ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -406,7 +701,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 471ddf0ec54..6021c99bb5e 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -1,20 +1,25 @@ """Tests for home_connect light entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.home_connect.const import ( - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, + BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + DOMAIN, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,26 +28,17 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, - STATE_UNKNOWN, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Hood" -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get(TEST_HC_APP) - .get("data") - .get("settings") -} - @pytest.fixture def platforms() -> list[str]: @@ -51,29 +47,202 @@ def platforms() -> list[str]: async def test_light( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + get_available_programs_mock = client.get_available_programs + + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +async def test_light_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if light entities availability are based on the appliance connection state.""" + entity_ids = [ + "light.hood_functional_light", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize( - ("entity_id", "status", "service", "service_data", "state", "appliance"), + ( + "entity_id", + "set_settings_args", + "service", + "exprected_attributes", + "state", + "appliance_ha_id", + ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, @@ -83,58 +252,18 @@ async def test_light( ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 200}, + {"brightness": 199}, STATE_ON, "Hood", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_OFF, - {}, - STATE_OFF, - "Hood", - ), - ( - "light.hood_functional_light", - { - COOKING_LIGHTING: { - "value": None, - }, - COOKING_LIGHTING_BRIGHTNESS: None, - }, - SERVICE_TURN_ON, - {}, - STATE_UNKNOWN, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_ON, - {"brightness": 200}, - STATE_ON, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: {"value": False}, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, @@ -144,8 +273,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, + }, + SERVICE_TURN_ON, + {"brightness": 199}, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False, + }, + SERVICE_TURN_OFF, + {}, + STATE_OFF, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, }, SERVICE_TURN_ON, {}, @@ -155,15 +304,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_COLOR: { - "value": "", - }, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, { - "rgb_color": [255, 255, 0], + "rgb_color": (255, 255, 0), + }, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, }, STATE_ON, "Hood", @@ -171,10 +333,7 @@ async def test_light( ( "light.fridgefreezer_external_light", { - REFRIGERATION_EXTERNAL_LIGHT_POWER: { - "value": True, - }, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True, }, SERVICE_TURN_ON, {}, @@ -182,167 +341,268 @@ async def test_light( "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_light_functionality( entity_id: str, - status: dict, + set_settings_args: dict[SettingKey, Any], service: str, - service_data: dict, + exprected_attributes: dict[str, Any], state: str, - appliance: Mock, - bypass_throttle: Generator[None], + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) + service_data = exprected_attributes.copy() service_data["entity_id"] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, - service_data, - blocking=True, + {key: value for key, value in service_data.items() if value is not None}, ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + client.set_setting.assert_has_calls( + [ + call(appliance_ha_id, setting_key=setting_key, value=value) + for setting_key, value in set_settings_args.items() + ] + ) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == state + for key, value in exprected_attributes.items(): + assert entity_state.attributes[key] == value @pytest.mark.parametrize( ( "entity_id", - "status", + "events", + "appliance_ha_id", + ), + [ + ( + "light.hood_ambient_light", + { + EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1", + }, + "Hood", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_light_color_different_than_custom( + entity_id: str, + events: dict[EventKey, Any], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that light color attributes are not set if color is different than custom.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "rgb_color": (255, 255, 0), + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is not None + assert entity_state.attributes["hs_color"] is not None + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + for event_key, value in events.items() + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is None + assert entity_state.attributes["hs_color"] is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "setting", "service", "service_data", - "mock_attr", "attr_side_effect", - "problematic_appliance", "exception_match", ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": False, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*off.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, None, HomeConnectError], - "Hood", + r"Error.*set.*brightness.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, HomeConnectError], + r"Error.*select.*custom color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, None, HomeConnectError], + r"Error.*set.*color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, + }, + [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), ], - indirect=["problematic_appliance"], ) -async def test_switch_exception_handling( +async def test_light_exception_handling( entity_id: str, - status: dict, + setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, - mock_attr: str, - attr_side_effect: list, - problematic_appliance: Mock, + attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" - problematic_appliance.status.update(SETTINGS_STATUS) - problematic_appliance.set_setting.side_effect = attr_side_effect - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value, + ) + for setting_key, value in setting.items() + ] + ) + client_with_exception.set_setting.side_effect = [ + exception() if exception else None for exception in attr_side_effect + ] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await client_with_exception.set_setting() - problematic_appliance.status.update(status) service_data["entity_id"] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) + assert client_with_exception.set_setting.call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bce19161cf8..214dcb6137c 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -1,32 +1,51 @@ """Tests for home_connect number entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import random -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.number import ( ATTR_MAX, ATTR_MIN, + ATTR_STEP, ATTR_VALUE as SERVICE_ATTR_VALUE, + DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError - -from .conftest import get_all_appliances +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -38,25 +57,198 @@ def platforms() -> list[str]: async def test_number( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test number entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True) +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + "Integer", + ) + ], + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + + def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return get_settings_original_mock.return_value + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +async def test_number_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if number entities availability are based on the appliance connection state.""" + entity_ids = [ + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + ] + + client.get_setting.side_effect = None + # Setting constrains are not needed for this test + # so we rise an error to easily test the availability + client.get_setting = AsyncMock(side_effect=HomeConnectError()) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", "setting_key", + "type", + "expected_state", "min_value", "max_value", "step_size", @@ -64,102 +256,132 @@ async def test_number( ), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, 7, 15, 0.1, "°C", ), + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, + 7, + 15, + 5, + "°C", + ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, + type: str, + expected_state: int, min_value: int, max_value: int, step_size: float, unit_of_measurement: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test number entity functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_MIN: min_value, - ATTR_MAX: max_value, - ATTR_STEPSIZE: step_size, - }, - ATTR_UNIT: unit_of_measurement, - } - ] - get_appliances.return_value = [appliance] - current_value = min_value - appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) + client.get_setting.side_effect = None + client.get_setting = AsyncMock( + return_value=GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # This should not change the value + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size if isinstance(step_size, int) else None, + ), + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, str(current_value)) - state = hass.states.get(entity_id) - assert state.attributes["min"] == min_value - assert state.attributes["max"] == max_value - assert state.attributes["step"] == step_size - assert state.attributes["unit_of_measurement"] == unit_of_measurement + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == str(expected_state) + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement - new_value = random.randint(min_value + 1, max_value) + value = random.choice( + [num for num in range(min_value, max_value + 1) if num != expected_state] + ) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - SERVICE_ATTR_VALUE: new_value, + SERVICE_ATTR_VALUE: value, }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test number entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=DEFAULT_MIN_VALUE, + constraints=SettingConstraints( + min=int(DEFAULT_MIN_VALUE), + max=int(DEFAULT_MAX_VALUE), + step_size=1, + ), + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -173,4 +395,136 @@ async def test_number_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + [ + ( + "Oven", + "number.oven_setpoint_temperature", + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + 50, + 260, + 1, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + min: int, + max: int, + step_size: int, + unit: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + unit=unit, + ) + ] + ), + ), + ] + ) + + called_mock = AsyncMock(side_effect=set_program_option_side_effect) + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + setattr(client, called_mock_method, called_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit=unit, + constraints=ProgramDefinitionConstraints( + min=min, + max=max, + step_size=step_size, + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit + assert entity_state.attributes[ATTR_MIN] == min + assert entity_state.attributes[ATTR_MAX] == max + assert entity_state.attributes[ATTR_STEP] == step_size + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, SERVICE_ATTR_VALUE: 80}, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": 80, + } + assert hass.states.is_state(entity_id, "80.0") diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index af975979196..22ece365e6b 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,39 +1,56 @@ """Tests for home_connect select entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + EnumerateProgram, + EnumerateProgramConstraints, + Execution, + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Washer") - .get("data") - .get("settings") -} - -PROGRAM = "Dishcare.Dishwasher.Program.Eco50" +from tests.common import MockConfigEntry @pytest.fixture @@ -43,119 +60,336 @@ def platforms() -> list[str]: async def test_select( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test select entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -async def test_filter_unknown_programs( - bypass_throttle: Generator[None], +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test select that programs that are not part of the official Home Connect API specification are filtered out. + """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + "Enumeration", + ) + ], + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED - We use two programs to ensure that programs are iterated over a copy of the list, - and it does not raise problems when removing an element from the original list. + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. """ - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [ - PROGRAM, - "NonOfficialProgram", - "AntotherNonOfficialProgram", + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + +async def test_select_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if select entities availability are based on the appliance connection state.""" + entity_ids = [ + "select.washer_active_program", ] - get_appliances.return_value = [appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +async def test_filter_programs( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test select that only right programs are shown.""" + client.get_all_programs.side_effect = None + client.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + constraints=EnumerateProgramConstraints( + execution=Execution.SELECT_ONLY, + ), + ), + EnumerateProgram( + key=ProgramKey.UNKNOWN, + raw_key="an unknown program", + ), + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_QUICK_45, + raw_key=ProgramKey.DISHCARE_DISHWASHER_QUICK_45.value, + constraints=EnumerateProgramConstraints( + execution=Execution.START_ONLY, + ), + ), + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + raw_key=ProgramKey.DISHCARE_DISHWASHER_AUTO_1.value, + constraints=EnumerateProgramConstraints( + execution=Execution.SELECT_AND_START, + ), + ), + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - entity = entity_registry.async_get("select.washer_selected_program") + entity = entity_registry.async_get("select.dishwasher_selected_program") assert entity - assert entity.capabilities.get(ATTR_OPTIONS) == [ - "dishcare_dishwasher_program_eco_50" + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == [ + "dishcare_dishwasher_program_eco_50", + "dishcare_dishwasher_program_auto_1", + ] + + entity = entity_registry.async_get("select.dishwasher_active_program") + assert entity + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == [ + "dishcare_dishwasher_program_quick_45", + "dishcare_dishwasher_program_auto_1", ] @pytest.mark.parametrize( - ("entity_id", "status", "program_to_set"), + ( + "appliance_ha_id", + "entity_id", + "expected_initial_state", + "mock_method", + "program_key", + "program_to_set", + "event_key", + ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_selected_program", + "dishcare_dishwasher_program_auto_1", + "set_selected_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_active_program", + "dishcare_dishwasher_program_auto_1", + "start_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], + indirect=["appliance_ha_id"], ) -async def test_select_functionality( +async def test_select_program_functionality( + appliance_ha_id: str, entity_id: str, - status: dict, + expected_initial_state: str, + mock_method: str, + program_key: ProgramKey, program_to_set: str, - bypass_throttle: Generator[None], + event_key: EventKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test select functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - appliance.status.update(status) + assert hass.states.is_state(entity_id, expected_initial_state) await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, - blocking=True, + ) + await hass.async_block_till_done() + getattr(client, mock_method).assert_awaited_once_with( + appliance_ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value="A not known program", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + @pytest.mark.parametrize( ( "entity_id", - "status", "program_to_set", "mock_attr", "exception_match", ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_selected_program", "dishcare_dishwasher_program_eco_50", - "select_program", + "set_selected_program", r"Error.*select.*program.*", ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", "start_program", r"Error.*start.*program.*", @@ -164,32 +398,36 @@ async def test_select_functionality( ) async def test_select_exception_handling( entity_id: str, - status: dict, program_to_set: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() - problematic_appliance.status.update(status) with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SELECT_DOMAIN, @@ -197,4 +435,316 @@ async def test_select_exception_handling( {"entity_id": entity_id, "option": program_to_set}, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "expected_options", + "value_to_set", + "expected_value_call_arg", + ), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + { + "cooking_hood_enum_type_color_temperature_warm", + "cooking_hood_enum_type_color_temperature_neutral", + "cooking_hood_enum_type_color_temperature_cold", + }, + "cooking_hood_enum_type_color_temperature_neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *[str(i) for i in range(1, 100)], + }, + "42", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ), + ], +) +async def test_select_functionality( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + expected_options: set[str], + value_to_set: str, + expected_value_call_arg: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test select functionality.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + ) + await hass.async_block_till_done() + + client.set_setting.assert_called_once() + assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.kwargs == { + "setting_key": setting_key, + "value": expected_value_call_arg, + } + assert hass.states.is_state(entity_id, value_to_set) + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "test_setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values( + appliance_ha_id: str, + entity_id: str, + test_setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + original_get_setting_side_effect = client.get_setting + + async def get_setting_side_effect( + ha_id: str, setting_key: SettingKey + ) -> GetSetting: + if ha_id != appliance_ha_id or setting_key != test_setting_key: + return await original_get_setting_side_effect(ha_id, setting_key) + return GetSetting( + key=test_setting_key, + raw_key=test_setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + + client.get_setting = AsyncMock(side_effect=get_setting_side_effect) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + +@pytest.mark.parametrize( + ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "cooking_hood_enum_type_color_temperature_neutral", + "set_setting", + ), + ], +) +async def test_select_entity_error( + entity_id: str, + setting_key: SettingKey, + allowed_value: str, + value_to_set: str, + mock_attr: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test select entity error.""" + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value_to_set, + constraints=SettingConstraints(allowed_values=[allowed_value]), + ) + ] + ) + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(HomeConnectError): + await getattr(client_with_exception, mock_attr)() + + with pytest.raises( + HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + blocking=True, + ) + assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "allowed_values", "expected_options"), + [ + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + None, + { + "laundry_care_washer_enum_type_temperature_cold", + "laundry_care_washer_enum_type_temperature_g_c_20", + "laundry_care_washer_enum_type_temperature_g_c_30", + "laundry_care_washer_enum_type_temperature_g_c_40", + "laundry_care_washer_enum_type_temperature_g_c_50", + "laundry_care_washer_enum_type_temperature_g_c_60", + "laundry_care_washer_enum_type_temperature_g_c_70", + "laundry_care_washer_enum_type_temperature_g_c_80", + "laundry_care_washer_enum_type_temperature_g_c_90", + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ], + { + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + allowed_values: list[str | None] | None, + expected_options: set[str], + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Enumeration", + constraints=ProgramDefinitionConstraints( + allowed_values=allowed_values + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", + } + assert hass.states.is_state( + entity_id, "laundry_care_washer_enum_type_temperature_ul_warm" + ) diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f2ee3b13922..31fc9ea6d3f 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,75 +1,81 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import HomeConnectApiError from freezegun.api import FrozenDateTimeFactory -from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Dishwasher" EVENT_PROG_DELAYED_START = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" - }, -} - -EVENT_PROG_REMAIN_NO_VALUE = { - "BSH.Common.Option.RemainingProgramTime": {}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, } EVENT_PROG_RUN = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "60"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", + }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60, }, } - EVENT_PROG_UPDATE_1 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "80"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_UPDATE_2 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "20"}, - "BSH.Common.Option.ProgramProgress": {"value": "99"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_END = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Ready" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready", }, } @@ -80,22 +86,177 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -# Appliance program sequence with a delayed start. +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_status_original_mock = client.get_status + + def get_status_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return get_status_original_mock.return_value + + client.get_status = AsyncMock(side_effect=get_status_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_status = get_status_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +async def test_sensor_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if sensor entities availability are based on the appliance connection state.""" + entity_ids = [ + "sensor.dishwasher_operation_state", + "sensor.dishwasher_salt_nearly_empty", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +# Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -130,7 +291,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -141,17 +302,16 @@ ENTITY_ID_STATES = { ) ), ) -@pytest.mark.usefixtures("bypass_throttle") async def test_event_sensors( - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, states: tuple, - event_run: dict, + event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, ) -> None: """Test sequence for sensors that are only available after an event happens.""" entity_ids = ENTITY_ID_STATES.keys() @@ -159,24 +319,48 @@ async def test_event_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_DELAYED_START) - assert await integration_setup() + client.get_status.return_value.status.extend( + Status( + key=StatusKey(event_key.value), + raw_key=event_key.value, + value=value, + ) + for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() + ) + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(event_run) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event_run.items() + for event_key, value in events.items() + ] + ) + await hass.async_block_till_done() for entity_id, state in zip(entity_ids, states, strict=False): - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ - EVENT_PROG_REMAIN_NO_VALUE, + EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, EVENT_PROG_END, EVENT_PROG_END, @@ -191,60 +375,86 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) -@pytest.mark.usefixtures("bypass_throttle") +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: Mock, + appliance_ha_id: str, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" - get_appliances.return_value = [appliance] entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED for ( event, expected_state, ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): - appliance.status.update(event) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event.items() + for event_key, value in events.items() + ] + ) await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ( + "entity_id", + "event_key", + "event_type", + "event_value_update", + "expected", + "appliance_ha_id", + ), [ ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_LOCKED, "locked", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_CLOSED, "closed", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_OPEN, "open", "Dishwasher", @@ -252,33 +462,38 @@ async def test_remaining_prog_time_edge_cases( ( "sensor.fridgefreezer_freezer_door_alarm", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + EventType.EVENT, "", "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "FridgeFreezer", ), ( "sensor.coffeemaker_bean_container_empty", + EventType.EVENT, "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "", "off", @@ -286,52 +501,150 @@ async def test_remaining_prog_time_edge_cases( ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "CoffeeMaker", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors_states( entity_id: str, - status_key: str, + event_key: EventKey, + event_type: EventType, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: - """Tests for Appliance alarm sensors.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] + """Tests for Appliance_ha_id alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ), + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit_get_status", + "unit_get_status_value", + "get_status_value_call_count", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + None, + 0, + ), + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + None, + "°C", + 1, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit_get_status: str | None, + unit_get_status_value: str | None, + get_status_value_call_count: int, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + return_value=Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status_value, + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert ( + entity_state.attributes["unit_of_measurement"] == unit_get_status + or unit_get_status_value + ) + + assert client.get_status_value.call_count == get_status_value_call_count diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index a02cb553ece..1b38809dc05 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,25 +1,40 @@ """Tests for home_connect sensor entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -30,26 +45,19 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Dishwasher") - .get("data") - .get("settings") -} - -PROGRAM = "LaundryCare.Dryer.Program.Mix" +from tests.common import MockConfigEntry @pytest.fixture @@ -59,231 +67,474 @@ def platforms() -> list[str]: async def test_switches( - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), - [ - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": ""}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": True}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": False}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ], - indirect=["appliance"], -) -async def test_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, - bypass_throttle: Generator[None], +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: - """Test switch functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - + """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + get_available_programs_mock = client.get_available_programs + + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +async def test_switch_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if switch entities availability are based on the appliance connection state.""" + entity_ids = [ + "switch.dishwasher_power", + "switch.dishwasher_child_lock", + "switch.dishwasher_program_eco50", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "entity_id", + "service", + "settings_key_arg", + "setting_value_arg", + "state", + "appliance_ha_id", + ), + [ + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_ON, + SettingKey.BSH_COMMON_CHILD_LOCK, + True, + STATE_ON, + "Dishwasher", + ), + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_OFF, + SettingKey.BSH_COMMON_CHILD_LOCK, + False, + STATE_OFF, + "Dishwasher", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_switch_functionality( + entity_id: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + service: str, + state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "program_key", "initial_state", "appliance_ha_id"), + [ + ( + "switch.dryer_program_mix", + ProgramKey.LAUNDRY_CARE_DRYER_MIX, + STATE_OFF, + "Dryer", + ), + ( + "switch.dryer_program_cotton", + ProgramKey.LAUNDRY_CARE_DRYER_COTTON, + STATE_ON, + "Dryer", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_program_switch_functionality( + entity_id: str, + program_key: ProgramKey, + initial_state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + async def mock_stop_program(ha_id: str) -> None: + """Mock stop program.""" + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ), + ] + ) + + client.stop_program = AsyncMock(side_effect=mock_stop_program) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.is_state(entity_id, initial_state) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_ON) + client.start_program.assert_awaited_once_with( + appliance_ha_id, program_key=program_key + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_OFF) + client.stop_program.assert_awaited_once_with(appliance_ha_id) @pytest.mark.parametrize( ( "entity_id", - "status", "service", "mock_attr", - "problematic_appliance", "exception_match", ), [ ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_ON, "start_program", - "Dishwasher", r"Error.*start.*program.*", ), ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_OFF, "stop_program", - "Dishwasher", r"Error.*stop.*program.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], ) async def test_switch_exception_handling( entity_id: str, - status: dict, service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_CHILD_LOCK, + raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value, + value=False, + ), + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=[BSH_POWER_ON, BSH_POWER_OFF] + ), + ), + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - problematic_appliance.status.update(status) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "status", "service", "state", "appliance_ha_id"), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_functionality( entity_id: str, status: dict, service: str, state: str, - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) @@ -293,13 +544,13 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "problematic_appliance", + "appliance_ha_id", "exception_match", ), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, "set_setting", "FridgeFreezer", @@ -307,229 +558,257 @@ async def test_ent_desc_switch_functionality( ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, - status: dict, + status: dict[SettingKey, str], service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" - problematic_appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(problematic_appliance.name) - .get("data") - .get("settings") - ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=key, + raw_key=key.value, + value=value, + ) + for key, value in status.items() + ] ) - get_appliances.return_value = [problematic_appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() - - problematic_appliance.status.update(status) + await client_with_exception.set_setting() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert client_with_exception.set_setting.call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "allowed_values", "service", "power_state", "appliance"), + ( + "entity_id", + "allowed_values", + "service", + "setting_value_arg", + "power_state", + "appliance_ha_id", + ), [ ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_OFF, + BSH_POWER_OFF, STATE_OFF, "Dishwasher", ), ( "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Run" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_power", - { - BSH_POWER_STATE: {"value": ""}, - BSH_OPERATION_STATE: { - "value": "BSH.Common.EnumType.OperationState.Inactive" - }, - }, - [BSH_POWER_ON], - SERVICE_TURN_ON, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_OFF, + BSH_POWER_STANDBY, STATE_OFF, "Dishwasher", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_swtich( entity_id: str, - status: dict, - allowed_values: list[str], + allowed_values: list[str | None] | None, service: str, + setting_value_arg: str, power_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test power switch functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - appliance.status.update(status) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value="", + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=setting_value_arg, ) assert hass.states.is_state(entity_id, power_state) @pytest.mark.parametrize( - ("entity_id", "allowed_values", "service", "appliance", "exception_match"), + ("initial_value"), + [ + (BSH_POWER_OFF), + (BSH_POWER_STANDBY), + ], +) +async def test_power_switch_fetch_off_state_from_current_value( + initial_value: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test power switch functionality to fetch the off state from the current value.""" + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=initial_value, + ) + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +@pytest.mark.parametrize( + ("entity_id", "allowed_values", "service", "exception_match"), [ ( "switch.dishwasher_power", [BSH_POWER_ON], SERVICE_TURN_OFF, - "Dishwasher", r".*not support.*turn.*off.*", ), ( "switch.dishwasher_power", None, SERVICE_TURN_OFF, - "Dishwasher", + r".*Unable.*turn.*off.*support.*not.*determined.*", + ), + ( + "switch.dishwasher_power", + HomeConnectError(), + SERVICE_TURN_OFF, r".*Unable.*turn.*off.*support.*not.*determined.*", ), ], - indirect=["appliance"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_switch_service_validation_errors( entity_id: str, - allowed_values: list[str], + allowed_values: list[str | None] | None | HomeConnectError, service: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, exception_match: str, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" - if allowed_values: - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + if isinstance(allowed_values, HomeConnectError): + exception = allowed_values + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + ) + ] + ) + client.get_setting = AsyncMock(side_effect=exception) + else: + setting = GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + client.get_settings.return_value = ArrayOfSettings([setting]) + client.get_setting = AsyncMock(return_value=setting) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, + appliance_ha_id: str, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "switch.washer_program_mix" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( @@ -566,7 +845,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" @@ -581,3 +860,95 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "appliance_ha_id"), + [ + ( + "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Dishwasher", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, options=[ProgramDefinitionOption(option_key, "Boolean")] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": False, + } + assert hass.states.is_state(entity_id, STATE_OFF) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": True, + } + assert hass.states.is_state(entity_id, STATE_ON) diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 1401e07b05a..affb5ecfedf 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -1,20 +1,27 @@ """Tests for home_connect time entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest -from homeassistant.components.home_connect.const import ATTR_VALUE +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError - -from .conftest import get_all_appliances +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -26,114 +33,257 @@ def platforms() -> list[str]: async def test_time( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test time entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_settings_original_mock = client.get_settings + + async def get_settings_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_settings_original_mock.side_effect(ha_id) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_settings = get_settings_original_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +async def test_time_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if time entities availability are based on the appliance connection state.""" + entity_ids = [ + "time.oven_alarm_clock", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( - ("entity_id", "setting_key", "setting_value", "expected_state"), + ("entity_id", "setting_key"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: 59}, - str(time(second=59)), - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: None}, - "unknown", - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - None, - "unknown", + SettingKey.BSH_COMMON_ALARM_CLOCK, ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - setting_value: dict, - expected_state: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test time entity functionality.""" - get_appliances.return_value = [appliance] - appliance.status.update({setting_key: setting_value}) - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, expected_state) - new_value = 30 - assert hass.states.get(entity_id).state != new_value + value = 30 + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state != value await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=new_value), + ATTR_TIME: time(second=value), }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(time(second=value))) -@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", + SettingKey.BSH_COMMON_ALARM_CLOCK, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test time entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=30, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -147,4 +297,4 @@ async def test_time_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1f1955c2f82..ec87672e75c 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -497,28 +497,48 @@ async def test_list_exposed_entities( entry1 = entity_registry.async_get_or_create("test", "test", "unique1") entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + entity_registry.async_get_or_create("test", "test", "unique3") # Set options for registered entities await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [entry1.entity_id, entry2.entity_id], + "entity_ids": [entry1.entity_id], "should_expose": True, } ) response = await ws_client.receive_json() assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry2.entity_id], + "should_expose": False, + } + ) + response = await ws_client.receive_json() + assert response["success"] + # Set options for entities not in the entity registry await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [ - "test.test", - "test.test2", - ], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": ["test.test2"], "should_expose": False, } ) @@ -531,10 +551,8 @@ async def test_list_exposed_entities( assert response["success"] assert response["result"] == { "exposed_entities": { - "test.test": {"cloud.alexa": False, "cloud.google_assistant": False}, - "test.test2": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test": {"cloud.alexa": True, "cloud.google_assistant": True}, "test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True}, - "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, }, } diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 56eeb4177b1..4facd1695c5 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -6,7 +6,7 @@ import pytest import voluptuous as vol import yaml -from homeassistant import config +from homeassistant import config, core as ha from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, ATTR_SAFE_MODE, @@ -30,7 +30,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import entity, entity_registry as er @@ -38,6 +37,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + MockEntityPlatform, MockUser, async_capture_events, async_mock_service, @@ -90,6 +90,8 @@ async def test_reload_core_conf(hass: HomeAssistant) -> None: ent = entity.Entity() ent.entity_id = "test.entity" ent.hass = hass + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([ent]) ent.async_write_ha_state() state = hass.states.get("test.entity") diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 85882274fec..0d4294ca16f 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component @@ -1770,8 +1770,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1938,8 +1937,7 @@ async def test_variables_priority( "entity_id": ["test.entity_1", "test.entity_2"], "above": above, "below": below, - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 83157a158a6..f6478e9dda0 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component @@ -1423,8 +1423,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", @@ -1727,8 +1726,7 @@ async def test_variables_priority( "platform": "state", "entity_id": ["test.entity_1", "test.entity_2"], "to": "world", - "for": '{{ 5 if trigger.entity_id == "test.entity_1"' - " else 10 }}", + "for": '{{ 5 if trigger.entity_id == "test.entity_1" else 10 }}', }, "action": { "service": "test.automation", diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 8900998a7b8..9a4f41d08e1 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component @@ -156,6 +156,86 @@ async def test_if_fires_using_at_input_datetime( ) +@pytest.mark.parametrize(("hour"), [0, 5, 23]) +@pytest.mark.parametrize( + ("has_date", "has_time"), [(True, True), (False, True), (True, False)] +) +@pytest.mark.parametrize( + ("offset", "delta"), + [ + ("00:00:10", timedelta(seconds=10)), + ("-00:00:10", timedelta(seconds=-10)), + ({"minutes": 5}, timedelta(minutes=5)), + ("01:00:10", timedelta(hours=1, seconds=10)), + ], +) +async def test_if_fires_using_at_input_datetime_with_offset( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], + has_date: bool, + has_time: bool, + offset: str, + delta: timedelta, + hour: int, +) -> None: + """Test for firing at input_datetime.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": has_date, "has_time": has_time}}}, + ) + now = dt_util.now() + + start_dt = now.replace( + hour=hour if has_time else 0, minute=0, second=0, microsecond=0 + ) + timedelta(2) + trigger_dt = start_dt + delta + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(start_dt.replace(tzinfo=None)), + }, + blocking=True, + ) + await hass.async_block_till_done() + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": {"entity_id": "input_datetime.trigger", "offset": offset}, + }, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 2 + assert ( + service_calls[1].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-input_datetime.trigger" + ) + + @pytest.mark.parametrize( ("conf_at", "trigger_deltas"), [ @@ -654,10 +734,6 @@ def test_schema_valid(conf) -> None: {"platform": "time", "at": "binary_sensor.bla"}, {"platform": "time", "at": 745}, {"platform": "time", "at": "25:00"}, - { - "platform": "time", - "at": {"entity_id": "input_datetime.bla", "offset": "0:10"}, - }, {"platform": "time", "at": {"entity_id": "13:00:00", "offset": "0:10"}}, ], ) diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 7138fd7dd02..ffce8cd476b 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant.triggers import time_pattern from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 145087073af..32c5a381233 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -17,12 +17,14 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, + FirmwareInfo, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, ) from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -64,13 +66,13 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): """Create the config entry.""" assert self._device is not None assert self._hardware_name is not None - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None return self.async_create_entry( title=self._hardware_name, data={ "device": self._device, - "firmware": self._probed_firmware_type.value, + "firmware": self._probed_firmware_info.firmware_type.value, "hardware": self._hardware_name, }, ) @@ -86,18 +88,26 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self._device = self.config_entry.data["device"] self._hardware_name = self.config_entry.data["hardware"] + self._probed_firmware_info = FirmwareInfo( + device=self._device, + firmware_type=ApplicationType(self.config_entry.data["firmware"]), + firmware_version=None, + source="guess", + owners=[], + ) + # Regenerate the translation placeholders self._get_translation_placeholders() def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None self.hass.config_entries.async_update_entry( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": self._probed_firmware_type.value, + "firmware": self._probed_firmware_info.firmware_type.value, }, options=self.config_entry.options, ) @@ -106,7 +116,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): @pytest.fixture(autouse=True) -def mock_test_firmware_platform( +async def mock_test_firmware_platform( hass: HomeAssistant, ) -> Generator[None]: """Fixture for a test config flow.""" @@ -116,6 +126,8 @@ def mock_test_firmware_platform( mock_integration(hass, mock_module) mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + await async_setup_component(hass, "homeassistant_hardware", {}) + with mock_config_flow(TEST_DOMAIN, FakeFirmwareConfigFlow): yield @@ -139,7 +151,7 @@ def mock_addon_info( hass: HomeAssistant, *, is_hassio: bool = True, - app_type: ApplicationType = ApplicationType.EZSP, + app_type: ApplicationType | None = ApplicationType.EZSP, otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -184,11 +196,26 @@ def mock_addon_info( ) mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info + if app_type is None: + firmware_info_result = None + else: + firmware_info_result = FirmwareInfo( + device="/dev/ttyUSB0", # Not used + firmware_type=app_type, + firmware_version=None, + owners=[], + source="probe", + ) + with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", return_value=mock_otbr_manager, ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=mock_otbr_manager, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager", return_value=mock_flasher_manager, @@ -198,8 +225,12 @@ def mock_addon_info( return_value=is_hassio, ), patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", - return_value=app_type, + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=is_hassio, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=firmware_info_result, ), ): yield mock_otbr_manager, mock_flasher_manager @@ -263,10 +294,14 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.data == { @@ -336,10 +371,14 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_configure(result["flow_id"]) # Done - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ): + await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_zigbee" async def test_config_flow_thread(hass: HomeAssistant) -> None: @@ -408,17 +447,21 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = result["result"] - assert config_entry.data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } + config_entry = result["result"] + assert config_entry.data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: @@ -466,10 +509,14 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: @@ -490,10 +537,10 @@ async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.data == { @@ -527,17 +574,17 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) - # First step is confirmation - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME - with mock_addon_info( hass, app_type=ApplicationType.EZSP, ) as (mock_otbr_manager, mock_flasher_manager): + # First step is confirmation + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + assert result["description_placeholders"]["firmware_type"] == "ezsp" + assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -588,14 +635,18 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ): + # We are now done + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - # The firmware type has been updated - assert config_entry.data["firmware"] == "spinel" + # The firmware type has been updated + assert config_entry.data["firmware"] == "spinel" async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: @@ -669,11 +720,15 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ): + # We are now done + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - # The firmware type has been updated - assert config_entry.data["firmware"] == "ezsp" + # The firmware type has been updated + assert config_entry.data["firmware"] == "ezsp" diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index f5375fb51dd..fb38704ae61 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -1,6 +1,6 @@ """Test the Home Assistant hardware firmware config flow failure cases.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +9,11 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -31,8 +35,8 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) @pytest.mark.parametrize( "next_step", @@ -65,8 +69,8 @@ async def test_config_flow_cannot_probe_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_not_hassio_wrong_firmware( hass: HomeAssistant, @@ -94,8 +98,8 @@ async def test_config_flow_zigbee_not_hassio_wrong_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_already_running( hass: HomeAssistant, @@ -132,8 +136,8 @@ async def test_config_flow_zigbee_flasher_addon_already_running( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -169,8 +173,8 @@ async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_install_fails( hass: HomeAssistant, @@ -203,8 +207,8 @@ async def test_config_flow_zigbee_flasher_addon_install_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_set_config_fails( hass: HomeAssistant, @@ -241,8 +245,8 @@ async def test_config_flow_zigbee_flasher_addon_set_config_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -306,8 +310,44 @@ async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio_thread"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], +) +async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: + """Test the config flow failing due to Zigbee firmware not being detected.""" + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + # Pick the menu option: we are now installing the addon + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_zigbee" + + with mock_addon_info( + hass, + app_type=None, # Probing fails + ) as (mock_otbr_manager, mock_flasher_manager): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_firmware" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: """Test when the stick is used with a non-hassio setup and Thread is selected.""" @@ -333,8 +373,8 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -361,8 +401,8 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.otbr_addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: """Test failure case when the Thread addon is already running.""" @@ -400,8 +440,8 @@ async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -431,8 +471,8 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be configured.""" @@ -462,8 +502,8 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -527,8 +567,50 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.zha_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], +) +async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: + """Test the config flow failing due to OpenThread firmware not being detected.""" + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_otbr" + + with mock_addon_info( + hass, + app_type=None, # Probing fails + ) as (mock_otbr_manager, mock_flasher_manager): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_firmware" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_zigbee_to_thread_zha_configured( hass: HomeAssistant, @@ -548,28 +630,35 @@ async def test_options_flow_zigbee_to_thread_zha_configured( assert await hass.config_entries.async_setup(config_entry.entry_id) - # Set up ZHA as well - zha_config_entry = MockConfigEntry( - domain="zha", - data={"device": {"path": TEST_DEVICE}}, - ) - zha_config_entry.add_to_hass(hass) + # Pretend ZHA is using the stick + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[ + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="zha", + owners=[OwningIntegration(config_entry_id="some_config_entry_id")], + ) + ], + ): + # Confirm options flow + result = await hass.config_entries.options.async_init(config_entry.entry_id) - # Confirm options flow - result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Pick Thread + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) - # Pick Thread - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "zha_still_using_stick" @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.otbr_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, diff --git a/tests/components/homeassistant_hardware/test_helpers.py b/tests/components/homeassistant_hardware/test_helpers.py new file mode 100644 index 00000000000..183995be7ce --- /dev/null +++ b/tests/components/homeassistant_hardware/test_helpers.py @@ -0,0 +1,185 @@ +"""Test hardware helpers.""" + +import logging +from unittest.mock import AsyncMock, MagicMock, Mock, call + +import pytest + +from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_callback, + async_register_firmware_info_provider, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +FIRMWARE_INFO_EZSP = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], +) + +FIRMWARE_INFO_SPINEL = FirmwareInfo( + device="/dev/serial/by-id/device2", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], +) + + +async def test_dispatcher_registration(hass: HomeAssistant) -> None: + """Test HardwareInfoDispatcher registration.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + + # Mock provider 1 with a synchronous method to pull firmware info + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + # Mock provider 2 with an asynchronous method to pull firmware info + provider2_config_entry = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id2", + data={}, + ) + provider2_config_entry.add_to_hass(hass) + provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider2_firmware = MagicMock(spec=["async_get_firmware_info"]) + provider2_firmware.async_get_firmware_info = AsyncMock( + return_value=FIRMWARE_INFO_SPINEL + ) + async_register_firmware_info_provider(hass, "otbr", provider2_firmware) + + # Double registration won't work + with pytest.raises(ValueError, match="Domain zha is already registered"): + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + # We can iterate over the results + info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()] + assert info == [ + FIRMWARE_INFO_EZSP, + FIRMWARE_INFO_SPINEL, + ] + + callback1 = Mock() + cancel1 = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device1", callback1 + ) + + callback2 = Mock() + cancel2 = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device2", callback2 + ) + + # And receive notification callbacks + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL) + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + cancel1() + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL) + cancel2() + + assert callback1.mock_calls == [ + call(FIRMWARE_INFO_EZSP), + call(FIRMWARE_INFO_EZSP), + ] + + assert callback2.mock_calls == [ + call(FIRMWARE_INFO_SPINEL), + call(FIRMWARE_INFO_SPINEL), + ] + + +async def test_dispatcher_iter_error_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test HardwareInfoDispatcher ignoring errors from firmware info providers.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(side_effect=Exception("Boom!")) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + provider2_config_entry = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id2", + data={}, + ) + provider2_config_entry.add_to_hass(hass) + provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider2_firmware = MagicMock(spec=["async_get_firmware_info"]) + provider2_firmware.async_get_firmware_info = AsyncMock( + return_value=FIRMWARE_INFO_SPINEL + ) + async_register_firmware_info_provider(hass, "otbr", provider2_firmware) + + with caplog.at_level(logging.ERROR): + info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()] + + assert info == [FIRMWARE_INFO_SPINEL] + assert "Error while getting firmware info from" in caplog.text + + +async def test_dispatcher_callback_error_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test HardwareInfoDispatcher ignoring errors from firmware info callbacks.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + callback1 = Mock(side_effect=Exception("Some error")) + async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback1) + + callback2 = Mock() + async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback2) + + with caplog.at_level(logging.ERROR): + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + + assert "Error while notifying firmware info listener" in caplog.text + + assert callback1.mock_calls == [call(FIRMWARE_INFO_EZSP)] + assert callback2.mock_calls == [call(FIRMWARE_INFO_EZSP)] diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 22e3e338986..fbba3d42bbe 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -450,10 +450,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.not_hassio"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_non_hassio( hass: HomeAssistant, ) -> None: @@ -766,10 +763,7 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_already_running"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_already_running_failure( hass: HomeAssistant, addon_info, @@ -881,10 +875,7 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_install_failure( hass: HomeAssistant, addon_info, @@ -951,10 +942,7 @@ async def test_option_flow_flasher_install_failure( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_addon_flash_failure( hass: HomeAssistant, addon_info, @@ -1017,10 +1005,7 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1082,10 +1067,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( mock_initiate_migration.assert_called_once() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), @@ -1187,10 +1169,7 @@ async def test_option_flow_do_not_install_multi_pan_addon( assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_install_fails( hass: HomeAssistant, addon_store_info, @@ -1234,10 +1213,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_start_fails( hass: HomeAssistant, addon_store_info, @@ -1299,10 +1275,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( assert result["reason"] == "addon_start_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_set_config_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_set_options_fails( hass: HomeAssistant, addon_store_info, @@ -1346,10 +1319,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( assert result["reason"] == "addon_set_config_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_info_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_addon_info_fails( hass: HomeAssistant, addon_store_info, @@ -1373,10 +1343,7 @@ async def test_option_flow_addon_info_fails( assert result["reason"] == "addon_info_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1432,10 +1399,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( set_addon_options.assert_not_called() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 3f019a0409c..b467380c431 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -1,18 +1,33 @@ """Test hardware utilities.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +import pytest +from universal_silabs_flasher.common import Version as FlasherVersion +from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType + +from homeassistant.components.hassio import ( + AddonError, + AddonInfo, + AddonManager, + AddonState, +) +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_provider, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, - FlasherApplicationType, - get_zha_device_path, - guess_firmware_type, + FirmwareInfo, + OwningAddon, + OwningIntegration, + get_otbr_addon_firmware_info, + guess_firmware_info, + probe_silabs_firmware_info, probe_silabs_firmware_type, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -21,7 +36,21 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( unique_id="some_unique_id", data={ "device": { - "path": "socket://1.2.3.4:5678", + "path": "/dev/ttyUSB1", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, + version=4, +) + +ZHA_CONFIG_ENTRY2 = MockConfigEntry( + domain="zha", + unique_id="some_other_unique_id", + data={ + "device": { + "path": "/dev/ttyUSB2", "baudrate": 115200, "flow_control": None, }, @@ -31,153 +60,321 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( ) -def test_get_zha_device_path() -> None: - """Test extracting the ZHA device path from its config entry.""" - assert ( - get_zha_device_path(ZHA_CONFIG_ENTRY) == ZHA_CONFIG_ENTRY.data["device"]["path"] - ) - - -def test_get_zha_device_path_ignored_discovery() -> None: - """Test extracting the ZHA device path from an ignored ZHA discovery.""" - config_entry = MockConfigEntry( - domain="zha", - unique_id="some_unique_id", - data={}, - version=4, - ) - - assert get_zha_device_path(config_entry) is None - - -async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: +async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" - assert (await guess_firmware_type(hass, "/dev/missing")) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" + await async_setup_component(hass, "homeassistant_hardware", {}) + + assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo( + device="/dev/missing", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], ) -async def test_guess_firmware_type(hass: HomeAssistant) -> None: - """Test guessing the firmware.""" - path = ZHA_CONFIG_ENTRY.data["device"]["path"] +async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: + """Test guessing the firmware via OTBR and ZHA.""" - ZHA_CONFIG_ENTRY.add_to_hass(hass) + await async_setup_component(hass, "homeassistant_hardware", {}) - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.NOT_LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="zha" + # One instance of ZHA and two OTBRs + zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") + zha.add_to_hass(hass) + + otbr1 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_2") + otbr1.add_to_hass(hass) + + otbr2 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_3") + otbr2.add_to_hass(hass) + + # First ZHA is running with the stick + zha_firmware_info = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], ) - # When ZHA is running, we indicate as such when guessing - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" + # First OTBR: neither the addon or the integration are loaded + otbr_firmware_info1 = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + AsyncMock(is_running=AsyncMock(return_value=False)), + AsyncMock(is_running=AsyncMock(return_value=False)), + ], ) - mock_otbr_addon_manager = AsyncMock() - mock_multipan_addon_manager = AsyncMock() + # Second OTBR: fully running but is with an unrelated device + otbr_firmware_info2 = FirmwareInfo( + device="/dev/serial/by-id/device2", # An unrelated device + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + AsyncMock(is_running=AsyncMock(return_value=True)), + AsyncMock(is_running=AsyncMock(return_value=True)), + ], + ) - with ( - patch( - "homeassistant.components.homeassistant_hardware.util.is_hassio", - return_value=True, + mock_zha_hardware_info = MagicMock(spec=["get_firmware_info"]) + mock_zha_hardware_info.get_firmware_info = MagicMock(return_value=zha_firmware_info) + async_register_firmware_info_provider(hass, "zha", mock_zha_hardware_info) + + async def mock_otbr_async_get_firmware_info( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> FirmwareInfo | None: + return { + otbr1.entry_id: otbr_firmware_info1, + otbr2.entry_id: otbr_firmware_info2, + }.get(config_entry.entry_id) + + mock_otbr_hardware_info = MagicMock(spec=["async_get_firmware_info"]) + mock_otbr_hardware_info.async_get_firmware_info = AsyncMock( + side_effect=mock_otbr_async_get_firmware_info + ) + async_register_firmware_info_provider(hass, "otbr", mock_otbr_hardware_info) + + # ZHA wins for the first stick, since it's actually running + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device1") + ) == zha_firmware_info + + # Second stick is communicating exclusively with the second OTBR + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device2") + ) == otbr_firmware_info2 + + # If we stop ZHA, OTBR will take priority + zha_firmware_info.owners[0].is_running.return_value = False + otbr_firmware_info1.owners[0].is_running.return_value = True + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device1") + ) == otbr_firmware_info1 + + +async def test_owning_addon(hass: HomeAssistant) -> None: + """Test `OwningAddon`.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + # Explicitly running + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + ) + assert (await owning_addon.is_running(hass)) is True + + # Explicitly not running + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + ) + assert (await owning_addon.is_running(hass)) is False + + # Failed to get status + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + side_effect=AddonError() + ) + assert (await owning_addon.is_running(hass)) is False + + +async def test_owning_integration(hass: HomeAssistant) -> None: + """Test `OwningIntegration`.""" + config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") + config_entry.add_to_hass(hass) + + owning_integration = OwningIntegration(config_entry_id=config_entry.entry_id) + + # Explicitly running + config_entry.mock_state(hass, ConfigEntryState.LOADED) + assert (await owning_integration.is_running(hass)) is True + + # Explicitly not running + config_entry.mock_state(hass, ConfigEntryState.NOT_LOADED) + assert (await owning_integration.is_running(hass)) is False + + # Missing config entry + owning_integration2 = OwningIntegration(config_entry_id="some_nonexistenct_id") + assert (await owning_integration2.is_running(hass)) is False + + +async def test_firmware_info(hass: HomeAssistant) -> None: + """Test `FirmwareInfo`.""" + + owner1 = AsyncMock() + owner2 = AsyncMock() + + firmware_info = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="zha", + owners=[owner1, owner2], + ) + + # Both running + owner1.is_running.return_value = True + owner2.is_running.return_value = True + assert (await firmware_info.is_running(hass)) is True + + # Only one running + owner1.is_running.return_value = True + owner2.is_running.return_value = False + assert (await firmware_info.is_running(hass)) is False + + # No owners + firmware_info2 = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="zha", + owners=[], + ) + + assert (await firmware_info2.is_running(hass)) is False + + +async def test_get_otbr_addon_firmware_info_failure(hass: HomeAssistant) -> None: + """Test getting OTBR addon firmware info failure due to bad API call.""" + + otbr_addon_manager = AsyncMock(spec_set=AddonManager) + otbr_addon_manager.async_get_addon_info.side_effect = AddonError() + + assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None + + +async def test_get_otbr_addon_firmware_info_failure_bad_options( + hass: HomeAssistant, +) -> None: + """Test getting OTBR addon firmware info failure due to bad addon options.""" + + otbr_addon_manager = AsyncMock(spec_set=AddonManager) + otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, # `device` is missing + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None + + +@pytest.mark.parametrize( + ("app_type", "firmware_version", "expected_fw_info"), + [ + ( + FlasherApplicationType.EZSP, + FlasherVersion("1.0.0"), + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="probe", + owners=[], + ), ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", - return_value=mock_otbr_addon_manager, + ( + FlasherApplicationType.EZSP, + None, + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="probe", + owners=[], + ), ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", - return_value=mock_multipan_addon_manager, + ( + FlasherApplicationType.SPINEL, + FlasherVersion("2.0.0"), + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.SPINEL, + firmware_version="2.0.0", + source="probe", + owners=[], + ), ), - ): - mock_otbr_addon_manager.async_get_addon_info.side_effect = AddonError() - mock_multipan_addon_manager.async_get_addon_info.side_effect = AddonError() + (None, None, None), + ], +) +async def test_probe_silabs_firmware_info( + app_type: FlasherApplicationType | None, + firmware_version: FlasherVersion | None, + expected_fw_info: FirmwareInfo | None, +) -> None: + """Test getting the firmware info.""" - # Hassio errors are ignored and we still go with ZHA - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) + def probe_app_type() -> None: + mock_flasher.app_type = app_type + mock_flasher.app_version = firmware_version - mock_otbr_addon_manager.async_get_addon_info.side_effect = None - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": "/some/other/device"}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # We will prefer ZHA, as it is running (and actually pointing to the device) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) - - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) - - # We will still prefer ZHA, as it is the one actually running - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) - - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Finally, ZHA loses out to OTBR - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.SPINEL, source="otbr" - ) - - mock_multipan_addon_manager.async_get_addon_info.side_effect = None - mock_multipan_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Which will lose out to multi-PAN - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol" - ) - - -async def test_probe_silabs_firmware_type() -> None: - """Test probing Silabs firmware type.""" + mock_flasher = MagicMock() + mock_flasher.app_type = None + mock_flasher.app_version = None + mock_flasher.probe_app_type = AsyncMock(side_effect=probe_app_type) with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=RuntimeError, + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, ): - assert (await probe_silabs_firmware_type("/dev/ttyUSB0")) is None + result = await probe_silabs_firmware_info("/dev/ttyUSB0") + assert result == expected_fw_info + +@pytest.mark.parametrize( + ("probe_result", "expected"), + [ + ( + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], + ), + ApplicationType.EZSP, + ), + (None, None), + ], +) +async def test_probe_silabs_firmware_type( + probe_result: FirmwareInfo | None, expected: ApplicationType | None +) -> None: + """Test getting the firmware type from the probe result.""" with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=lambda self: setattr(self, "app_type", FlasherApplicationType.EZSP), + "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", autospec=True, - ) as mock_probe_app_type: - # The application type constant is converted back and forth transparently - result = await probe_silabs_firmware_type( - "/dev/ttyUSB0", probe_methods=[ApplicationType.EZSP] - ) - assert result is ApplicationType.EZSP - - flasher = mock_probe_app_type.mock_calls[0].args[0] - assert flasher._probe_methods == [FlasherApplicationType.EZSP] + return_value=probe_result, + ): + result = await probe_silabs_firmware_type("/dev/ttyUSB0") + assert result == expected diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 055b6347267..d8542002ae8 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components import usb from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, @@ -14,13 +13,18 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon get_flasher_addon_manager, get_multiprotocol_addon_manager, ) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry -USB_DATA_SKY = usb.UsbServiceInfo( +USB_DATA_SKY = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -29,7 +33,7 @@ USB_DATA_SKY = usb.UsbServiceInfo( description="SkyConnect v1.0", ) -USB_DATA_ZBT1 = usb.UsbServiceInfo( +USB_DATA_ZBT1 = UsbServiceInfo( device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", vid="10C4", pid="EA60", @@ -47,7 +51,7 @@ USB_DATA_ZBT1 = usb.UsbServiceInfo( ], ) async def test_config_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the config flow for SkyConnect.""" result = await hass.config_entries.flow.async_init( @@ -61,10 +65,22 @@ async def test_config_flow( async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) - with patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -102,7 +118,7 @@ async def test_config_flow( ], ) async def test_options_flow( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test the options flow for SkyConnect.""" config_entry = MockConfigEntry( @@ -134,10 +150,22 @@ async def test_options_flow( async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) - with patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), + ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -168,7 +196,7 @@ async def test_options_flow( ], ) async def test_options_flow_multipan_uninstall( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant ) -> None: """Test options flow for when multi-PAN firmware is installed.""" config_entry = MockConfigEntry( diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 15eeb205537..8e90039a4fc 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant @@ -32,11 +32,13 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( - "homeassistant.components.homeassistant_sky_connect.guess_firmware_type", - return_value=FirmwareGuess( - is_running=True, + "homeassistant.components.homeassistant_sky_connect.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + firmware_version=None, firmware_type=ApplicationType.SPINEL, source="otbr", + owners=[], ), ): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 1d1d70c1b4c..2801b3d00bb 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -8,7 +8,7 @@ from homeassistant.components.homeassistant_sky_connect.util import ( get_hardware_variant, get_usb_service_info, ) -from homeassistant.components.usb import UsbServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 1067be7b56e..78fd45c6b5b 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -18,7 +18,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon get_flasher_addon_manager, get_multiprotocol_addon_manager, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) from homeassistant.components.homeassistant_yellow.const import DOMAIN, RADIO_DEVICE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -82,8 +85,14 @@ async def test_config_flow(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), ), ): result = await hass.config_entries.flow.async_init( @@ -330,10 +339,22 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) - with patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), + ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 5d534dad1e7..57d63c7441e 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import zha from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -49,11 +49,13 @@ async def test_setup_entry( return_value=onboarded, ), patch( - "homeassistant.components.homeassistant_yellow.guess_firmware_type", - return_value=FirmwareGuess( # Nothing is setup - is_running=False, + "homeassistant.components.homeassistant_yellow.guess_firmware_info", + return_value=FirmwareInfo( # Nothing is setup + device="/dev/ttyAMA1", + firmware_version=None, firmware_type=ApplicationType.EZSP, source="unknown", + owners=[], ), ), ): diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index 03095aca7df..432e2d68516 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -1 +1,60 @@ """Tests for the homee component.""" + +from typing import Any +from unittest.mock import AsyncMock + +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def build_mock_node(file: str) -> AsyncMock: + """Build a mocked Homee node from a json representation.""" + json_node = load_json_object_fixture(file, DOMAIN) + mock_node = AsyncMock(spec=HomeeNode) + + def get_attributes(attributes: list[Any]) -> list[AsyncMock]: + mock_attributes: list[AsyncMock] = [] + for attribute in attributes: + att = AsyncMock(spec=HomeeAttribute) + for key, value in attribute.items(): + setattr(att, key, value) + att.is_reversed = False + att.get_value = ( + lambda att=att: att.data if att.unit == "text" else att.current_value + ) + mock_attributes.append(att) + return mock_attributes + + for key, value in json_node.items(): + if key != "attributes": + setattr(mock_node, key, value) + + mock_node.attributes = get_attributes(json_node["attributes"]) + + def attribute_by_type(type, instance=0) -> HomeeAttribute | None: + return {attr.type: attr for attr in mock_node.attributes}.get(type) + + mock_node.get_attribute_by_type = attribute_by_type + + return mock_node + + +async def async_update_attribute_value( + hass: HomeAssistant, attribute: AsyncMock, value: float +) -> None: + """Set the current_value of an attribute and notify hass.""" + attribute.current_value = value + attribute.add_on_changed_listener.call_args_list[0][0][0](attribute) + await hass.async_block_till_done() diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index 881a24656f3..5a3234e896b 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -1,9 +1,9 @@ """Fixtures for Homee integration tests.""" +from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from typing_extensions import Generator from homeassistant.components.homee.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -17,6 +17,15 @@ TESTUSER = "testuser" TESTPASS = "testpass" +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -32,15 +41,6 @@ def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Mock setting up a config entry.""" - with patch( - "homeassistant.components.homee.async_setup_entry", return_value=True - ) as mock_setup: - yield mock_setup - - @pytest.fixture def mock_homee() -> Generator[AsyncMock]: """Return a mock Homee instance.""" @@ -50,7 +50,7 @@ def mock_homee() -> Generator[AsyncMock]: ) as mocked_homee, patch( "homeassistant.components.homee.Homee", - autospec=True, + new=mocked_homee, ), ): homee = mocked_homee.return_value @@ -61,7 +61,10 @@ def mock_homee() -> Generator[AsyncMock]: homee.settings = MagicMock() homee.settings.uid = HOMEE_ID homee.settings.homee_name = HOMEE_NAME + homee.settings.version = "1.2.3" + homee.settings.mac_address = "00:05:55:11:ee:cc" homee.reconnect_interval = 10 + homee.connected = True homee.get_access_token.return_value = "test_token" diff --git a/tests/components/homee/fixtures/buttons.json b/tests/components/homee/fixtures/buttons.json new file mode 100644 index 00000000000..306aed39f65 --- /dev/null +++ b/tests/components/homee/fixtures/buttons.json @@ -0,0 +1,274 @@ +{ + "id": 1, + "name": "Test Button", + "profile": 2015, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 19, + "routing": 0, + "state": 1, + "state_changed": 1676561556, + "added": 1675835814, + "history": 1, + "cube_type": 17, + "note": "# Hörmann Garagentor Serie 3", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 326, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 327, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 170, + "state": 1, + "last_changed": 1672148539, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 304, + "state": 1, + "last_changed": 1739125922, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 304, + "state": 1, + "last_changed": 1739125922, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 304, + "state": 1, + "last_changed": 1739125922, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 305, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 306, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 328, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 347, + "state": 1, + "last_changed": 1682166450, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 347, + "state": 1, + "last_changed": 1682166450, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 378, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/cover_with_position_slats.json b/tests/components/homee/fixtures/cover_with_position_slats.json new file mode 100644 index 00000000000..8fd0d6f44fe --- /dev/null +++ b/tests/components/homee/fixtures/cover_with_position_slats.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": -45.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover_with_slats_position.json b/tests/components/homee/fixtures/cover_with_slats_position.json new file mode 100644 index 00000000000..4b6eb466a85 --- /dev/null +++ b/tests/components/homee/fixtures/cover_with_slats_position.json @@ -0,0 +1,71 @@ +{ + "id": 1, + "name": "Test Slats", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1676901608, + "added": 1672148537, + "history": 1, + "cube_type": 14, + "note": "", + "services": 70, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": 1.0, + "target_value": 1.0, + "last_value": -21.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 337, + "state": 1, + "last_changed": 1678284911, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [72] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover_without_position.json b/tests/components/homee/fixtures/cover_without_position.json new file mode 100644 index 00000000000..e2bc6c7a38d --- /dev/null +++ b/tests/components/homee/fixtures/cover_without_position.json @@ -0,0 +1,48 @@ +{ + "id": 3, + "name": "Test Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/light_single.json b/tests/components/homee/fixtures/light_single.json new file mode 100644 index 00000000000..30932da8679 --- /dev/null +++ b/tests/components/homee/fixtures/light_single.json @@ -0,0 +1,102 @@ +{ + "id": 2, + "name": "Another Test Light", + "profile": 1002, + "image": "default", + "favorite": 0, + "order": 48, + "protocol": 21, + "sub_protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1694024544, + "added": 1679551927, + "history": 1, + "cube_type": 8, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 12, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 13, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 14, + "node_id": 2, + "instance": 0, + "minimum": 2000, + "maximum": 7000, + "current_value": 3700.0, + "target_value": 3700.0, + "last_value": 3700.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/lights.json b/tests/components/homee/fixtures/lights.json new file mode 100644 index 00000000000..3363b93fd77 --- /dev/null +++ b/tests/components/homee/fixtures/lights.json @@ -0,0 +1,333 @@ +{ + "id": 1, + "name": "Test Light", + "profile": 1002, + "image": "default", + "favorite": 0, + "order": 48, + "protocol": 21, + "sub_protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1694024544, + "added": 1679551927, + "history": 1, + "cube_type": 8, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 3, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1073741824, + "current_value": 16763000, + "target_value": 16763000, + "last_value": 16763000, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 23, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "7001020;16419669;12026363;16525995", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 1, + "minimum": 153, + "maximum": 500, + "current_value": 366.0, + "target_value": 366.0, + "last_value": 366.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 5, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 7, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1073741824, + "current_value": 16763000, + "target_value": 16763000, + "last_value": 16763000, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 23, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "7001020;16419669;12026363;16525995", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 2, + "minimum": 2202, + "maximum": 4000, + "current_value": 3000.0, + "target_value": 3000.0, + "last_value": 3000.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 9, + "node_id": 1, + "instance": 3, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 10, + "node_id": 1, + "instance": 3, + "minimum": 0, + "maximum": 100, + "current_value": 40.0, + "target_value": 40.0, + "last_value": 40.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1736743291, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 11, + "node_id": 1, + "instance": 4, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 12, + "node_id": 1, + "instance": 4, + "minimum": 2200, + "maximum": 4000, + "current_value": 3000.0, + "target_value": 3000.0, + "last_value": 3000.0, + "unit": "K", + "step_value": 1.0, + "editable": 0, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json new file mode 100644 index 00000000000..c8773a89568 --- /dev/null +++ b/tests/components/homee/fixtures/numbers.json @@ -0,0 +1,337 @@ +{ + "id": 1, + "name": "Test Number", + "profile": 2011, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1731020474, + "added": 1680027411, + "history": 1, + "cube_type": 3, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 0.5, + "editable": 1, + "type": 349, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": -75, + "maximum": 75, + "current_value": 38.0, + "target_value": 38.0, + "last_value": 38.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 350, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 111, + "state": 1, + "last_changed": 1615396252, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 130, + "current_value": 129.0, + "target_value": 129.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 325, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 15300, + "current_value": 10.0, + "target_value": 1.0, + "last_value": 10.0, + "unit": "s", + "step_value": 1.0, + "editable": 0, + "type": 28, + "state": 1, + "last_changed": 1676204559, + "changed_by": 0, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 3, + "current_value": 3.0, + "target_value": 3.0, + "last_value": 2.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 261, + "state": 1, + "last_changed": 1666336770, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 5, + "maximum": 45, + "current_value": 30.0, + "target_value": 30.0, + "last_value": 0.0, + "unit": "min", + "step_value": 5.0, + "editable": 1, + "type": 88, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 24, + "current_value": 1.6, + "target_value": 1.6, + "last_value": 0.0, + "unit": "s", + "step_value": 0.1, + "editable": 1, + "type": 114, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": 75.0, + "target_value": 75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 323, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": -75.0, + "target_value": -75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 322, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 20, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 174, + "state": 1, + "last_changed": 1672149083, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": -5, + "maximum": 128, + "current_value": -3, + "target_value": -3, + "last_value": 128.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 64, + "state": 6, + "last_changed": 1711799534, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 110, + "state": 1, + "last_changed": 1615396246, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 30, + "maximum": 7200, + "current_value": 600.0, + "target_value": 600.0, + "last_value": 600.0, + "unit": "min", + "step_value": 30.0, + "editable": 1, + "type": 29, + "state": 1, + "last_changed": 1739333970, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 240, + "current_value": 12.0, + "target_value": 12.0, + "last_value": 12.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 29, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "fixed_value", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json new file mode 100644 index 00000000000..f4a7f462218 --- /dev/null +++ b/tests/components/homee/fixtures/sensors.json @@ -0,0 +1,715 @@ +{ + "id": 1, + "name": "Test MultiSensor", + "profile": 4010, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 200000, + "current_value": 555.591, + "target_value": 555.591, + "last_value": 555.586, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 200000, + "current_value": 1730.812, + "target_value": 1730.812, + "last_value": 1730.679, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 65000, + "current_value": 175.0, + "target_value": 175.0, + "last_value": 66.0, + "unit": "lx", + "step_value": 1.0, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 1, + "maximum": 100, + "current_value": 7.0, + "target_value": 7.0, + "last_value": 8.0, + "unit": "klx", + "step_value": 0.5, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1700056686, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 70, + "current_value": 0.249, + "target_value": 0.249, + "last_value": 0.249, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 193, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 70, + "current_value": 0.812, + "target_value": 0.812, + "last_value": 0.252, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 193, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 500, + "current_value": 500.0, + "target_value": 500.0, + "last_value": 500.0, + "unit": "lx", + "step_value": 2.0, + "editable": 0, + "type": 301, + "state": 1, + "last_changed": 1700056347, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -40, + "maximum": 100, + "current_value": 44.12, + "target_value": 44.12, + "last_value": 44.27, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 92, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4095, + "current_value": 2000.0, + "target_value": 0.0, + "last_value": 1800.0, + "unit": "1/min", + "step_value": 1.0, + "editable": 0, + "type": 103, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 47.0, + "target_value": 47.0, + "last_value": 47.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 96, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": -64, + "maximum": 63, + "current_value": 18.0, + "target_value": 18.0, + "last_value": 18.0, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 98, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4095, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "1/min", + "step_value": 1.0, + "editable": 0, + "type": 102, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 99999, + "current_value": 2490.0, + "target_value": 2490.0, + "last_value": 2516.0, + "unit": "L", + "step_value": 1.0, + "editable": 0, + "type": 22, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 17, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 4.0, + "target_value": 4.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 33, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 18, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 196605, + "current_value": 5478.0, + "target_value": 5478.0, + "last_value": 5478.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 104, + "state": 1, + "last_changed": 1736105231, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 19, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 33.0, + "target_value": 33.0, + "last_value": 32.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 95, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 20, + "node_id": 1, + "instance": 0, + "minimum": -64, + "maximum": 63, + "current_value": 17.0, + "target_value": 17.0, + "last_value": 17.0, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 97, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 21, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 15, + "state": 1, + "last_changed": 1694176210, + "changed_by": 2, + "changed_by_id": 2, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 22, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 51.0, + "target_value": 51.0, + "last_value": 51.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 7, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 23, + "node_id": 1, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 20.3, + "target_value": 20.3, + "last_value": 20.3, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 24, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 600000, + "current_value": 3657.822, + "target_value": 3657.822, + "last_value": 3657.377, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 240, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 25, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 200, + "current_value": 2.223, + "target_value": 2.223, + "last_value": 2.21, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 272, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 26, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 80000, + "current_value": 195.384, + "target_value": 195.384, + "last_value": 248.412, + "unit": "W", + "step_value": 1.0, + "editable": 0, + "type": 239, + "state": 1, + "last_changed": 1694176076, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 27, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 420, + "current_value": 239.823, + "target_value": 239.823, + "last_value": 235.775, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 51, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 28, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 3.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 29, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 15, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 6.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 173, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 30, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 420, + "current_value": 239.823, + "target_value": 239.823, + "last_value": 239.559, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 195, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 31, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 420, + "current_value": 236.867, + "target_value": 236.867, + "last_value": 237.634, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 195, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 32, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 25, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.5, + "unit": "m/s", + "step_value": 1.0, + "editable": 0, + "type": 146, + "state": 1, + "last_changed": 1700056836, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 33, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 10, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/switch_single.json b/tests/components/homee/fixtures/switch_single.json new file mode 100644 index 00000000000..74b7fae048d --- /dev/null +++ b/tests/components/homee/fixtures/switch_single.json @@ -0,0 +1,74 @@ +{ + "id": 2, + "name": "Test Switch Single", + "profile": 15, + "image": "nodeicon_bulb", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 385, + "state": 1, + "last_changed": 1735663169, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 0, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/switches.json b/tests/components/homee/fixtures/switches.json new file mode 100644 index 00000000000..333717591a7 --- /dev/null +++ b/tests/components/homee/fixtures/switches.json @@ -0,0 +1,127 @@ +{ + "id": 1, + "name": "Test Switch", + "profile": 10, + "image": "nodeicon_dimmablebulb", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "All known switches", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 309, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 91, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 385, + "state": 1, + "last_changed": 1735663169, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 0, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/valve.json b/tests/components/homee/fixtures/valve.json new file mode 100644 index 00000000000..2b622cca6b1 --- /dev/null +++ b/tests/components/homee/fixtures/valve.json @@ -0,0 +1,51 @@ +{ + "id": 1, + "name": "Test Valve", + "profile": 3011, + "image": "nodeicon_valve", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_button.ambr b/tests/components/homee/snapshots/test_button.ambr new file mode 100644 index 00000000000..be2bbae539b --- /dev/null +++ b/tests/components/homee/snapshots/test_button.ambr @@ -0,0 +1,566 @@ +# serializer version: 1 +# name: test_button_snapshot[button.test_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button', + }), + 'context': , + 'entity_id': 'button.test_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_automatic_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_automatic_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_mode', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_automatic_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Automatic mode', + }), + 'context': , + 'entity_id': 'button.test_button_automatic_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_briefly_open-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_briefly_open', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Briefly open', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'briefly_open', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_briefly_open-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Briefly open', + }), + 'context': , + 'entity_id': 'button.test_button_briefly_open', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_identification_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_button_identification_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identification mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'identification_mode', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_identification_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Test Button Identification mode', + }), + 'context': , + 'entity_id': 'button.test_button_identification_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_impulse_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Impulse 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'impulse_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Impulse 1', + }), + 'context': , + 'entity_id': 'button.test_button_impulse_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_impulse_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Impulse 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'impulse_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Impulse 2', + }), + 'context': , + 'entity_id': 'button.test_button_impulse_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Light', + }), + 'context': , + 'entity_id': 'button.test_button_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_open_partially-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_open_partially', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open partially', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_partial', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_open_partially-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Open partially', + }), + 'context': , + 'entity_id': 'button.test_button_open_partially', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_open_permanently-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_open_permanently', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open permanently', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'permanently_open', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_open_permanently-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Open permanently', + }), + 'context': , + 'entity_id': 'button.test_button_open_permanently', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_button_reset_meter_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset meter 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_meter_instance', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Reset meter 1', + }), + 'context': , + 'entity_id': 'button.test_button_reset_meter_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_button_reset_meter_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset meter 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_meter_instance', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Reset meter 2', + }), + 'context': , + 'entity_id': 'button.test_button_reset_meter_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_ventilate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_ventilate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ventilate', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilate', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_ventilate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Ventilate', + }), + 'context': , + 'entity_id': 'button.test_button_ventilate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/homee/snapshots/test_light.ambr b/tests/components/homee/snapshots/test_light.ambr new file mode 100644 index 00000000000..3c766552467 --- /dev/null +++ b/tests/components/homee/snapshots/test_light.ambr @@ -0,0 +1,348 @@ +# serializer version: 1 +# name: test_light_snapshot[light.another_test_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.another_test_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-2-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.another_test_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 270, + 'color_temp_kelvin': 3700, + 'friendly_name': 'Another Test Light', + 'hs_color': tuple( + 26.996, + 40.593, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 255, + 198, + 151, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.44, + 0.371, + ), + }), + 'context': , + 'entity_id': 'light.another_test_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 500, + 'max_mireds': 6535, + 'min_color_temp_kelvin': 153, + 'min_mireds': 2000, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light Light 1', + 'hs_color': tuple( + 35.556, + 52.941, + ), + 'max_color_temp_kelvin': 500, + 'max_mireds': 6535, + 'min_color_temp_kelvin': 153, + 'min_mireds': 2000, + 'rgb_color': tuple( + 255, + 200, + 120, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.464, + 0.402, + ), + }), + 'context': , + 'entity_id': 'light.test_light_light_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 4000, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 250, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light Light 2', + 'hs_color': None, + 'max_color_temp_kelvin': 4000, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 250, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.test_light_light_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_snapshot[light.test_light_light_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 3', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 102, + 'color_mode': , + 'friendly_name': 'Test Light Light 3', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_light_light_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 4', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Test Light Light 4', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_light_light_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr new file mode 100644 index 00000000000..04b1aefab00 --- /dev/null +++ b/tests/components/homee/snapshots/test_number.ambr @@ -0,0 +1,802 @@ +# serializer version: 1 +# name: test_number_snapshot[number.test_number_down_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_movement_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Down-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_time', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_down_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Down-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_down_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Down position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_position', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_number_down_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_slat_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Down slat position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_slat_position', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down slat position', + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_down_slat_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_end_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'End position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'endposition_configuration', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number End position', + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_end_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '129', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_maximum_slat_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_max_angle', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Maximum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_maximum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_minimum_slat_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_min_angle', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Minimum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_minimum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-75', + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion alarm delay', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm_cancelation_delay', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Motion alarm delay', + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_polling_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Polling interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'polling_interval', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Polling interval', + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_polling_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_steps', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Slat steps', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_steps', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Slat steps', + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_slat_steps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Slat turn duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shutter_slat_time', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Slat turn duration', + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Temperature offset', + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_up_movement_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Up-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_time', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Up-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_up_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_wake_up_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wake-up interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake_up_interval', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Wake-up interval', + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_wake_up_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_window_open_sensibility', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Window open sensibility', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_window_detection_sensibility', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Window open sensibility', + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_window_open_sensibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3101723232e --- /dev/null +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -0,0 +1,1811 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.test_multisensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test MultiSensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_battery_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test MultiSensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_battery_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_current_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_instance', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Current 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_current_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.249', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_instance', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.812', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_dawn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_dawn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dawn', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dawn', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_dawn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Dawn', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_dawn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_device_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Device temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_temperature', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Device temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_device_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.12', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_energy_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_instance', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Energy 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_energy_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '555.591', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_energy_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_instance', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Energy 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_energy_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1730.812', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Exhaust motor speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_motor_revs', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Exhaust motor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2000.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 1', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '175.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 2', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7000.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_indoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_humidity', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Indoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_indoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_indoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_temperature', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Indoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_indoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_intake_motor_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intake motor speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'intake_motor_revs', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Intake motor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_intake_motor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'level', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Test MultiSensor Level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2490.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_link_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_link_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link quality', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_node_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'unavailable', + 'update_in_progress', + 'waiting_for_attributes', + 'initializing', + 'user_interaction_required', + 'password_required', + 'host_unavailable', + 'delete_in_progress', + 'cosi_connected', + 'blocked', + 'waiting_for_wakeup', + 'remote_node_deleted', + 'firmware_update_in_progress', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_node_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Node state', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'node_state', + 'unique_id': '00055511EECC-1-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_node_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor Node state', + 'options': list([ + 'available', + 'unavailable', + 'update_in_progress', + 'waiting_for_attributes', + 'initializing', + 'user_interaction_required', + 'password_required', + 'host_unavailable', + 'delete_in_progress', + 'cosi_connected', + 'blocked', + 'waiting_for_wakeup', + 'remote_node_deleted', + 'firmware_update_in_progress', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_node_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_operating_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_operating_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operating hours', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_hours', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_operating_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test MultiSensor Operating hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_operating_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5478.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_humidity', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'position', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'open', + 'closed', + 'partial', + 'opening', + 'closing', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_down', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor State', + 'options': list([ + 'open', + 'closed', + 'partial', + 'opening', + 'closing', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total current', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_current', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Total current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.223', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3657.822', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test MultiSensor Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '195.384', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_total_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total voltage', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_voltage', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Total voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.823', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_ultraviolet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_ultraviolet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ultraviolet', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_ultraviolet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Ultraviolet', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_ultraviolet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_voltage_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_instance', + 'unique_id': '00055511EECC-1-30', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Voltage 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_voltage_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.823', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_instance', + 'unique_id': '00055511EECC-1-31', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.867', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': '00055511EECC-1-32', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Test MultiSensor Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_window_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'closed', + 'open', + 'tilted', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_window_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'window_position', + 'unique_id': '00055511EECC-1-33', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_window_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor Window position', + 'options': list([ + 'closed', + 'open', + 'tilted', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_window_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/homee/snapshots/test_switch.ambr b/tests/components/homee/snapshots/test_switch.ambr new file mode 100644 index 00000000000..43c1773cede --- /dev/null +++ b/tests/components/homee/snapshots/test_switch.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_switch_snapshot[switch.test_switch_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_switch_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_binary_input', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Switch Child lock', + }), + 'context': , + 'entity_id': 'switch.test_switch_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_manual_operation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_switch_manual_operation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Manual operation', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_operation', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_manual_operation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Switch Manual operation', + }), + 'context': , + 'entity_id': 'switch.test_switch_manual_operation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_off_instance', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Test Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.test_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_switch_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_off_instance', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Test Switch Switch 2', + }), + 'context': , + 'entity_id': 'switch.test_switch_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_watchdog-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_switch_watchdog', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Watchdog', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'watchdog', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_watchdog-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Switch Watchdog', + }), + 'context': , + 'entity_id': 'switch.test_switch_watchdog', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/snapshots/test_valve.ambr b/tests/components/homee/snapshots/test_valve.ambr new file mode 100644 index 00000000000..c76ecc6e780 --- /dev/null +++ b/tests/components/homee/snapshots/test_valve.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_valve_snapshot[valve.test_valve_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.test_valve_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_valve_snapshot[valve.test_valve_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'water', + 'friendly_name': 'Test Valve Valve position', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.test_valve_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/homee/test_button.py b/tests/components/homee/test_button.py new file mode 100644 index 00000000000..fc7b018805f --- /dev/null +++ b/tests/components/homee/test_button.py @@ -0,0 +1,50 @@ +"""Test Homee buttons.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_button_press( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test press button service.""" + mock_homee.nodes = [build_mock_node("buttons.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_button_impulse_1"}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 5, 1) + + +async def test_button_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("buttons.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py new file mode 100644 index 00000000000..4f85b2dd7cc --- /dev/null +++ b/tests/components/homee/test_cover.py @@ -0,0 +1,286 @@ +"""Test homee covers.""" + +from unittest.mock import MagicMock + +import pytest +from websockets import frames +from websockets.exceptions import ConnectionClosed + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, + CoverState, +) +from homeassistant.components.homee.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry + + +async def test_open_close_stop_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test opening the cover.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls): + assert call[0] == (mock_homee.nodes[0].id, 1, index) + + +async def test_set_cover_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the cover position.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [0, 100, 50] + for call in calls: + assert call[0] == (1, 2, positions.pop(0)) + + +async def test_close_open_slats( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test closing and opening slats.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("cover.test_slats").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls, start=1): + assert call[0] == (mock_homee.nodes[0].id, 2, index) + + +async def test_set_slat_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting slats position.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90 on this device. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [-45, 90, 22.5] + for call in calls: + assert call[0] == (1, 1, positions.pop(0)) + + +async def test_cover_positions( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test an open cover.""" + # Cover open, tilt open. + # mock_homee.nodes = [cover] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_TILT_POSITION + ) + assert attributes.get("current_position") == 100 + assert attributes.get("current_tilt_position") == 100 + + cover.attributes[0].current_value = 1 + cover.attributes[1].current_value = 100 + cover.attributes[2].current_value = 90 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 0 + assert attributes.get("current_tilt_position") == 0 + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + + cover.attributes[0].current_value = 3 + cover.attributes[1].current_value = 75 + cover.attributes[2].current_value = 56 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.OPENING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 25 + assert attributes.get("current_tilt_position") == 25 + + cover.attributes[0].current_value = 4 + cover.attributes[1].current_value = 25 + cover.attributes[2].current_value = -11 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 75 + assert attributes.get("current_tilt_position") == 74 + + +async def test_reversed_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a cover with inverted UP_DOWN attribute without position.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + cover.attributes[0].is_reversed = True + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + cover.attributes[0].current_value = 0 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + + +async def test_send_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test failed set_value command.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + + await setup_integration(hass, mock_config_entry) + + mock_homee.set_value.side_effect = ConnectionClosed( + rcvd=frames.Close(1002, "Protocol Error"), sent=None + ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "connection_closed" diff --git a/tests/components/homee/test_light.py b/tests/components/homee/test_light.py new file mode 100644 index 00000000000..c8af4f6b23d --- /dev/null +++ b/tests/components/homee/test_light.py @@ -0,0 +1,158 @@ +"""Test homee lights.""" + +from typing import Any +from unittest.mock import MagicMock, call, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +def mock_attribute_map(attributes) -> dict: + """Mock the attribute map of a Homee node.""" + attribute_map = {} + for a in attributes: + attribute_map[a.type] = a + + return attribute_map + + +async def setup_mock_light( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + file: str, +) -> None: + """Setups the light node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.nodes[0].attribute_map = mock_attribute_map( + mock_homee.nodes[0].attributes + ) + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("data", "calls"), + [ + ({}, [call(1, 1, 1)]), + ({ATTR_BRIGHTNESS: 255}, [call(1, 2, 100)]), + ( + { + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_TEMP_KELVIN: 4300, + }, + [call(1, 2, 100), call(1, 4, 4300)], + ), + ({ATTR_HS_COLOR: (100, 100)}, [call(1, 1, 1), call(1, 3, 5635840)]), + ], +) +async def test_turn_on( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test turning on the light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_light_light_1"} | data, + blocking=True, + ) + assert mock_homee.set_value.call_args_list == calls + + +async def test_turn_off( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off a light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 0) + + +async def test_toggle( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test toggling a light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 0) + + mock_homee.nodes[0].attributes[0].current_value = 0.0 + mock_homee.nodes[0].add_on_changed_listener.call_args_list[0][0][0]( + mock_homee.nodes[0] + ) + await hass.async_block_till_done() + mock_homee.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 1) + + +async def test_light_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of lights.""" + mock_homee.nodes = [ + build_mock_node("lights.json"), + build_mock_node("light_single.json"), + ] + for i in range(2): + mock_homee.nodes[i].attribute_map = mock_attribute_map( + mock_homee.nodes[i].attributes + ) + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py new file mode 100644 index 00000000000..73ca707c2d5 --- /dev/null +++ b/tests/components/homee/test_number.py @@ -0,0 +1,74 @@ +"""Test Homee nmumbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value service.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + blocking=True, + ) + number = mock_homee.nodes[0].attributes[0] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + + +async def test_set_value_not_editable( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value if attribute is not editable.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_motion_alarm_delay", ATTR_VALUE: 10000}, + blocking=True, + ) + assert not mock_homee.set_value.called + assert not hass.states.async_available("number.test_number_motion_alarm_delay") + + +async def test_number_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py new file mode 100644 index 00000000000..a2ba991c49b --- /dev/null +++ b/tests/components/homee/test_sensor.py @@ -0,0 +1,125 @@ +"""Test homee sensors.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homee.const import ( + OPEN_CLOSE_MAP, + OPEN_CLOSE_MAP_REVERSED, + WINDOW_MAP, + WINDOW_MAP_REVERSED, +) +from homeassistant.const import LIGHT_LUX, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_update_attribute_value, build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +async def test_up_down_values( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test values for up/down sensor.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] + + attribute = mock_homee.nodes[0].attributes[27] + for i in range(1, 5): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[i] + ) + + # Test reversed up/down sensor + attribute.is_reversed = True + for i in range(5): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_state").state + == OPEN_CLOSE_MAP_REVERSED[i] + ) + + +async def test_window_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test values for window handle position.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP[0] + ) + + attribute = mock_homee.nodes[0].attributes[32] + for i in range(1, 3): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP[i] + ) + + # Test reversed window handle. + attribute.is_reversed = True + for i in range(3): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP_REVERSED[i] + ) + + +async def test_brightness_sensor( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test brightness sensor's lx & klx units and naming of multi-instance sensors.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + sensor_state = hass.states.get("sensor.test_multisensor_illuminance_1") + assert sensor_state.state == "175.0" + assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX + assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 1" + + # Sensor with Homee unit klx + sensor_state = hass.states.get("sensor.test_multisensor_illuminance_2") + assert sensor_state.state == "7000.0" + assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX + assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 2" + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_switch.py b/tests/components/homee/test_switch.py new file mode 100644 index 00000000000..bb14313f487 --- /dev/null +++ b/tests/components/homee/test_switch.py @@ -0,0 +1,179 @@ +"""Test Homee switches.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from websockets import frames +from websockets.exceptions import ConnectionClosed + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + SwitchDeviceClass, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switch_state( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the correct state is returned.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.test_switch_switch_1").state is not STATE_ON + switch = mock_homee.nodes[0].attributes[2] + switch.current_value = 1 + switch.add_on_changed_listener.call_args_list[0][0][0](switch) + await hass.async_block_till_done() + assert hass.states.get("switch.test_switch_switch_1").state is STATE_ON + + +async def test_switch_turn_on( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turn-on service.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.test_switch_switch_1").state is not STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_switch_1"}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 3, 1) + + +async def test_switch_turn_off( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turn-off service.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.test_switch_watchdog").state is STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_switch_watchdog"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 5, 0) + + +async def test_switch_device_class( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if device class gets set correctly.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("switch.test_switch_switch_1").attributes["device_class"] + == SwitchDeviceClass.OUTLET + ) + assert ( + hass.states.get("switch.test_switch_watchdog").attributes["device_class"] + == SwitchDeviceClass.SWITCH + ) + + +async def test_switch_no_name( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch gets no name when it is the main feature of the device.""" + mock_homee.nodes = [build_mock_node("switch_single.json")] + mock_homee.nodes[0].profile = 2002 + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("switch.test_switch_single").attributes["friendly_name"] + == "Test Switch Single" + ) + + +async def test_switch_device_class_no_outlet( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if on_off device class gets set correctly if node-profile is not a plug.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.nodes[0].profile = 2002 + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("switch.test_switch_switch_1").attributes["device_class"] + == SwitchDeviceClass.SWITCH + ) + + +async def test_send_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test failed set_value command.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + mock_homee.set_value.side_effect = ConnectionClosed( + rcvd=frames.Close(1002, "Protocol Error"), sent=None + ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_switch_1"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "connection_closed" + + +async def test_switch_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_valve.py b/tests/components/homee/test_valve.py new file mode 100644 index 00000000000..166b52cc07b --- /dev/null +++ b/tests/components/homee/test_valve.py @@ -0,0 +1,110 @@ +"""Test Homee valves.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + ValveEntityFeature, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_valve_set_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set valve position service.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_valve_valve_position", "position": 100}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 100) + + +@pytest.mark.parametrize( + ("current_value", "target_value", "state"), + [ + (0.0, 0.0, STATE_CLOSED), + (0.0, 100.0, STATE_OPENING), + (100.0, 0.0, STATE_CLOSING), + (100.0, 100.0, STATE_OPEN), + ], +) +async def test_opening_closing( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + current_value: float, + target_value: float, + state: str, +) -> None: + """Test if opening/closing is detected correctly.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + valve.current_value = current_value + valve.target_value = target_value + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + assert hass.states.get("valve.test_valve_valve_position").state == state + + +async def test_supported_features( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test supported features.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature.SET_POSITION + + valve.editable = 0 + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature(0) + + +async def test_valve_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the valve snapshots.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.VALVE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index fb059b93a13..5bad7aa8f39 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,7 @@ """Test different accessory types: Lights.""" from datetime import timedelta +import sys from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -41,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service @@ -540,6 +541,422 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 +async def test_light_invalid_hs_color( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light that starts out with an invalid hs color.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: 260, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + assert hasattr(acc, "char_color_temp") + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 224 + assert acc.char_hue.value == 27 + assert acc.char_saturation.value == 27 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 352 + assert acc.char_hue.value == 28 + assert acc.char_saturation.value == 61 + + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 250, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 50, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 50, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250" + ) + + # Only set Hue + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 30, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)" + + # Only set Saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 20, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[2] + assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20) + + assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)" + + # Generate a conflict by setting hue and then color temp + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 80, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 320, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[3] + assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125 + assert events[-1].data[ATTR_VALUE] == "color temperature at 320" + + # Generate a conflict by setting color temp then saturation + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 404, + } + ] + }, + "mock_addr", + ) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 35, + } + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on[4] + assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35) + assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)" + + # Set from HASS + hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)}) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert acc.char_color_temp.value == 404 + assert acc.char_hue.value == 100 + assert acc.char_saturation.value == 100 + + +async def test_light_invalid_values( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test light with a variety of invalid values.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 0 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 153 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 500 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + + +async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None: + """Test light with an out of range color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + +async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test light with a reversed color temp min max.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "hs", + ATTR_COLOR_TEMP_KELVIN: 2000, + ATTR_MAX_COLOR_TEMP_KELVIN: 3000, + ATTR_MIN_COLOR_TEMP_KELVIN: 4000, + ATTR_HS_COLOR: (-1, -1), + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_color_temp.value == 333 + assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333 + assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250 + assert acc.char_hue.value == 31 + assert acc.char_saturation.value == 95 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: -1, + }, + ) + await hass.async_block_till_done() + acc.run() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 16 + assert acc.char_saturation.value == 100 + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_MAX_COLOR_TEMP_KELVIN: 4000, + ATTR_MIN_COLOR_TEMP_KELVIN: 3000, + ATTR_COLOR_TEMP_KELVIN: sys.maxsize, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"], + ATTR_COLOR_MODE: "color_temp", + ATTR_COLOR_TEMP_KELVIN: 2000, + }, + ) + await hass.async_block_till_done() + + assert acc.char_color_temp.value == 250 + assert acc.char_hue.value == 220 + assert acc.char_saturation.value == 41 + + @pytest.mark.parametrize( "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] ) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 0d19763e4c7..141141e7f15 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -42,7 +42,7 @@ from homeassistant.const import ( STATE_OPEN, ) from homeassistant.core import Event, HomeAssistant, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e99db8f6234..fc4cfa78ca4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_STEP, DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, FAN_AUTO, FAN_HIGH, @@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, + ATTR_MAX_TEMP: 100, + ATTR_MIN_TEMP: 50, } hass.states.async_set( entity_id, @@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No acc.run() await hass.async_block_till_done() - assert acc.char_cooling_thresh_temp.value == 100 - assert acc.char_heating_thresh_temp.value == 100 + assert acc.char_cooling_thresh_temp.value == 50 + assert acc.char_heating_thresh_temp.value == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_target_heat_cool.value == 3 @@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No }, ) await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 100.0 + assert acc.char_heating_thresh_temp.value == 50.0 assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_current_heat_cool.value == 1 assert acc.char_target_heat_cool.value == 3 @@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver) assert call_set_hvac_mode assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None: + """Test reversed min/max temperatures.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + ATTR_MAX_TEMP: DEFAULT_MAX_TEMP, + ATTR_MIN_TEMP: DEFAULT_MIN_TEMP, + } + # support_auto = True + hass.states.async_set( + entity_id, + HVACMode.OFF, + base_attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 853db54b992..1da12402a56 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -26,6 +26,7 @@ from homeassistant.components.homekit.const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + CONF_VIDEO_PROFILE_NAMES, DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_PACKET_SIZE, @@ -39,6 +40,7 @@ from homeassistant.components.homekit.const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + DEFAULT_VIDEO_PROFILE_NAMES, DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, @@ -235,6 +237,7 @@ def test_validate_entity_config() -> None: CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP, CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, + CONF_VIDEO_PROFILE_NAMES: DEFAULT_VIDEO_PROFILE_NAMES, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index b94a267104b..e2aaf58d63e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index eea3f4b67f2..4e787f305b6 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -9,7 +9,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa: F401 diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 2bd5e7faf75..a41964d98cc 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -7,6 +7,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -42,6 +47,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -85,6 +91,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -135,6 +142,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -185,6 +193,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -233,6 +242,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -277,6 +287,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -321,6 +332,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -373,6 +385,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -432,6 +445,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -482,6 +496,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -522,6 +537,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -562,6 +578,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -605,6 +622,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -640,6 +662,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -680,6 +703,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -715,6 +743,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -756,6 +785,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -797,6 +827,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -840,6 +871,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -884,6 +916,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -923,6 +956,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -958,6 +996,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -999,6 +1038,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1040,6 +1080,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -1083,6 +1124,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1127,6 +1169,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -1166,6 +1209,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1201,6 +1249,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -1242,6 +1291,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1283,6 +1333,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -1326,6 +1377,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1370,6 +1422,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -1413,6 +1466,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1448,6 +1506,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'alarm_control_panel', @@ -1492,6 +1551,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1538,6 +1598,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -1582,6 +1643,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -1621,6 +1683,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1656,6 +1723,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -1697,6 +1765,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1740,6 +1809,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1787,6 +1857,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1822,6 +1897,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'alarm_control_panel', @@ -1866,6 +1942,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1916,6 +1993,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -1977,6 +2055,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -2021,6 +2100,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -2064,6 +2144,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -2099,6 +2184,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -2142,6 +2228,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2189,6 +2276,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -2224,6 +2316,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -2265,6 +2358,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -2306,6 +2400,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -2356,6 +2451,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -2414,6 +2510,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2458,6 +2555,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2504,6 +2602,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2549,6 +2648,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2592,6 +2692,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -2632,6 +2733,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -2675,6 +2777,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -2710,6 +2817,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -2753,6 +2861,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2798,6 +2907,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2843,6 +2953,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2888,6 +2999,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2933,6 +3045,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2978,6 +3091,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3021,6 +3135,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -3062,6 +3177,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -3106,6 +3222,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3141,6 +3262,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3182,6 +3304,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3225,6 +3348,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3267,6 +3391,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3302,6 +3431,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3343,6 +3473,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3384,6 +3515,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3424,6 +3556,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3476,6 +3609,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -3540,6 +3674,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -3590,6 +3725,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -3636,6 +3772,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3681,6 +3818,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3723,6 +3861,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3758,6 +3901,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3799,6 +3943,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3842,6 +3987,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3884,6 +4030,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3919,6 +4070,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3960,6 +4112,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4003,6 +4156,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4049,6 +4203,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4084,6 +4243,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4125,6 +4285,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4166,6 +4327,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4206,6 +4368,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4258,6 +4421,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -4322,6 +4486,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -4372,6 +4537,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -4418,6 +4584,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4463,6 +4630,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4509,6 +4677,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4544,6 +4717,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4585,6 +4759,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4625,6 +4800,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4660,6 +4840,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4712,6 +4893,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -4775,6 +4957,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -4821,6 +5004,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4866,6 +5050,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4908,6 +5093,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4943,6 +5133,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4984,6 +5175,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5027,6 +5219,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5069,6 +5262,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -5104,6 +5302,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5145,6 +5344,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5188,6 +5388,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5234,6 +5435,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -5269,6 +5475,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5310,6 +5517,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5351,6 +5559,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5391,6 +5600,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5447,6 +5657,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -5516,6 +5727,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -5566,6 +5778,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -5612,6 +5825,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5657,6 +5871,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5703,6 +5918,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -5738,6 +5958,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5779,6 +6000,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5820,6 +6042,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5863,6 +6086,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5908,6 +6132,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5951,6 +6176,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -5994,6 +6220,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6029,6 +6260,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6075,6 +6307,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -6124,6 +6357,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -6170,6 +6404,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6215,6 +6450,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6261,6 +6497,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6306,6 +6543,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6352,6 +6590,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6387,6 +6630,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6430,6 +6674,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6475,6 +6720,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6520,6 +6766,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6565,6 +6812,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6608,6 +6856,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -6649,6 +6898,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -6692,6 +6942,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6727,6 +6982,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6768,6 +7024,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6808,6 +7065,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6851,6 +7109,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -6899,6 +7158,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6934,6 +7198,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6975,6 +7240,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -7018,6 +7284,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7053,6 +7324,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7094,6 +7366,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -7138,6 +7411,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -7181,6 +7455,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7216,6 +7495,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7256,6 +7536,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7291,6 +7576,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7332,6 +7618,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -7376,6 +7663,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -7423,6 +7711,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7458,6 +7751,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7501,6 +7795,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -7545,6 +7840,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7580,6 +7880,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7620,6 +7921,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7655,6 +7961,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7698,6 +8005,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -7747,6 +8055,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7782,6 +8095,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7833,6 +8147,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -7887,6 +8202,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -7938,6 +8254,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -7984,6 +8301,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8029,6 +8347,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8071,6 +8390,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8106,6 +8430,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8150,6 +8475,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8185,6 +8515,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8225,6 +8556,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8260,6 +8596,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8305,6 +8642,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -8353,6 +8691,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8400,6 +8739,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8435,6 +8779,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8476,6 +8821,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -8520,6 +8866,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8563,6 +8910,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8598,6 +8950,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8638,6 +8991,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8673,6 +9031,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8714,6 +9073,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -8758,6 +9118,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8805,6 +9166,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8840,6 +9206,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8883,6 +9250,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -8927,6 +9295,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8962,6 +9335,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9002,6 +9376,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9037,6 +9416,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9080,6 +9460,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -9130,6 +9511,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9165,6 +9551,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9205,6 +9592,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9240,6 +9632,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9283,6 +9676,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -9333,6 +9727,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9368,6 +9767,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9423,6 +9823,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -9482,6 +9883,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -9533,6 +9935,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -9579,6 +9982,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -9624,6 +10028,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -9666,6 +10071,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9701,6 +10111,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9745,6 +10156,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9780,6 +10196,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9820,6 +10237,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9855,6 +10277,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9903,6 +10326,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'humidifier', @@ -9956,6 +10380,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10002,6 +10427,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10037,6 +10467,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10077,6 +10508,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10112,6 +10548,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10160,6 +10597,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'humidifier', @@ -10213,6 +10651,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10259,6 +10698,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10294,6 +10738,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10334,6 +10779,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10369,6 +10819,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10419,6 +10870,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -10477,6 +10929,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10524,6 +10977,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10559,6 +11017,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10616,6 +11075,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -10678,6 +11138,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10724,6 +11185,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10759,6 +11225,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10808,6 +11275,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -10862,6 +11330,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10897,6 +11370,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10946,6 +11420,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11000,6 +11475,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11035,6 +11515,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11084,6 +11565,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11138,6 +11620,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11173,6 +11660,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11222,6 +11710,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11276,6 +11765,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11311,6 +11805,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11360,6 +11855,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11424,6 +11920,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11459,6 +11960,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11508,6 +12010,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11572,6 +12075,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11607,6 +12115,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11652,6 +12161,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11701,6 +12211,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11750,6 +12261,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11799,6 +12311,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11846,6 +12359,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -11889,6 +12403,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11924,6 +12443,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11969,6 +12489,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12014,6 +12535,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12049,6 +12575,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12094,6 +12621,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12139,6 +12667,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12174,6 +12707,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12219,6 +12753,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12264,6 +12799,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12299,6 +12839,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12344,6 +12885,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12389,6 +12931,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12424,6 +12971,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12469,6 +13017,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12514,6 +13063,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12549,6 +13103,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12594,6 +13149,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12639,6 +13195,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12674,6 +13235,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12719,6 +13281,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12764,6 +13327,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12799,6 +13367,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12843,6 +13412,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12878,6 +13452,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12928,6 +13503,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12987,6 +13563,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13022,6 +13603,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13065,6 +13647,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13108,6 +13691,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13152,6 +13736,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13187,6 +13776,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13230,6 +13820,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13273,6 +13864,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13313,6 +13905,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13356,6 +13949,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13391,6 +13989,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13441,6 +14040,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -13501,6 +14101,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -13547,6 +14148,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13592,6 +14194,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13638,6 +14241,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13673,6 +14281,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13724,6 +14333,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'media_player', @@ -13776,6 +14386,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13819,6 +14430,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13854,6 +14470,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13897,6 +14514,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -13941,6 +14559,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13976,6 +14599,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14020,6 +14644,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14055,6 +14684,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14096,6 +14726,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14136,6 +14767,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14176,6 +14808,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14216,6 +14849,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14256,6 +14890,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14299,6 +14934,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14334,6 +14974,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14379,6 +15020,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -14428,6 +15070,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14463,6 +15110,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14513,6 +15161,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -14570,6 +15219,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -14621,6 +15271,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -14667,6 +15318,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -14712,6 +15364,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -14758,6 +15411,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14793,6 +15451,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14843,6 +15502,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -14918,6 +15578,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -14977,6 +15638,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15030,6 +15692,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15065,6 +15732,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -15106,6 +15774,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15147,6 +15816,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -15194,6 +15864,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -15241,6 +15912,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15281,6 +15953,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15324,6 +15997,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15359,6 +16037,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -15400,6 +16079,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -15441,6 +16121,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15485,6 +16166,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15520,6 +16206,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15563,6 +16250,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15607,6 +16295,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15652,6 +16341,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15697,6 +16387,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15742,6 +16433,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15788,6 +16480,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15823,6 +16520,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15864,6 +16562,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15907,6 +16606,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15950,6 +16650,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15993,6 +16694,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16036,6 +16738,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16079,6 +16782,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16122,6 +16826,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16165,6 +16870,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16211,6 +16917,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16246,6 +16957,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16287,6 +16999,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16331,6 +17044,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16374,6 +17088,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16409,6 +17128,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16449,6 +17169,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16484,6 +17209,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16525,6 +17251,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16569,6 +17296,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16616,6 +17344,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16651,6 +17384,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16692,6 +17426,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16736,6 +17471,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16779,6 +17515,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16814,6 +17555,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16855,6 +17597,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16899,6 +17642,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16942,6 +17686,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16977,6 +17726,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17018,6 +17768,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -17062,6 +17813,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -17105,6 +17857,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17140,6 +17897,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17180,6 +17938,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17215,6 +17978,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17256,6 +18020,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -17300,6 +18065,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -17347,6 +18113,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17382,6 +18153,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17423,6 +18195,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'lock', @@ -17467,6 +18240,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17502,6 +18280,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17545,6 +18324,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -17595,6 +18375,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -17644,6 +18425,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17679,6 +18465,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17720,6 +18507,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -17766,6 +18554,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17801,6 +18594,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17852,6 +18646,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -17907,6 +18702,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -17950,6 +18746,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -17990,6 +18787,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18031,6 +18829,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18072,6 +18871,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18113,6 +18913,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18157,6 +18958,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18192,6 +18998,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18235,6 +19042,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18280,6 +19088,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18325,6 +19134,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18371,6 +19181,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18406,6 +19221,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18446,6 +19262,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18481,6 +19302,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18524,6 +19346,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18569,6 +19392,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18614,6 +19438,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18656,6 +19481,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18691,6 +19521,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18732,6 +19563,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -18778,6 +19610,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18813,6 +19650,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18854,6 +19692,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -18900,6 +19739,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18935,6 +19779,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18976,6 +19821,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -19021,6 +19867,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -19056,6 +19907,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -19104,6 +19956,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'humidifier', @@ -19164,6 +20017,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -19235,6 +20089,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -19281,6 +20136,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -19327,6 +20183,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -19362,6 +20223,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -19405,6 +20267,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -19448,6 +20311,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index a16cd052c87..a71465716c4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -12,7 +12,7 @@ from homeassistant.components.homekit_controller.connection import ( MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..common import Helper, setup_accessories_from_file, setup_test_accessories diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 4fb0a80cd26..424f93f7142 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -15,7 +15,6 @@ from bleak.exc import BleakError import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.components.homekit_controller.storage import async_get_entity_storage @@ -23,6 +22,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -176,9 +179,9 @@ def get_flow_context( def get_device_discovery_info( device, upper_case_props=False, missing_csharp=False, paired=False -) -> zeroconf.ZeroconfServiceInfo: +) -> ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" - result = zeroconf.ZeroconfServiceInfo( + result = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, @@ -187,7 +190,7 @@ def get_device_discovery_info( properties={ "md": device.description.model, "pv": "1.0", - zeroconf.ATTR_PROPERTIES_ID: device.description.id, + ATTR_PROPERTIES_ID: device.description.id, "c#": device.description.config_num, "s#": device.description.state_num, "ff": "0", @@ -330,7 +333,7 @@ async def test_id_missing(hass: HomeAssistant, controller) -> None: discovery_info = get_device_discovery_info(device) # Remove id from device - del discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] + del discovery_info.properties[ATTR_PROPERTIES_ID] # Device is discovered result = await hass.config_entries.flow.async_init( @@ -346,7 +349,7 @@ async def test_discovery_ignored_model(hass: HomeAssistant, controller) -> None: """Already paired.""" device = setup_mock_accessory(controller) discovery_info = get_device_discovery_info(device) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" discovery_info.properties["md"] = "HHKBridge1,1" # Device is discovered @@ -375,7 +378,7 @@ async def test_discovery_ignored_hk_bridge( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -403,7 +406,7 @@ async def test_discovery_does_not_ignore_non_homekit( connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}, ) - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -582,7 +585,7 @@ async def test_discovery_already_configured_update_csharp( # Set device as already paired discovery_info.properties["sf"] = 0x00 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -967,7 +970,7 @@ async def test_discovery_dismiss_existing_flow_on_paired( # Set device as already not paired discovery_info.properties["sf"] = 0x01 discovery_info.properties["c#"] = 99999 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1201,7 +1204,7 @@ async def test_discovery_updates_ip_when_config_entry_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( @@ -1239,7 +1242,7 @@ async def test_discovery_updates_ip_config_entry_not_set_up( # Set device as already paired discovery_info.properties["sf"] = 0x00 - discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + discovery_info.properties[ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" # Device is discovered result = await hass.config_entries.flow.async_init( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 7ea791f9a1e..00c7bb16259 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -375,9 +375,9 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 7)} + # Verify everything is polled + assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} + assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} # Test device goes offline helper.pairing.available = False @@ -429,8 +429,8 @@ async def test_manual_poll_all_chars( ) as mock_get_characteristics: # Initial state is that the light is off await helper.poll_and_get_state() - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} + # Verify poll polls all chars + assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 # Now do a manual poll to ensure all chars are polled mock_get_characteristics.reset_mock() diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 7a3d3f06b09..ff57cd168c9 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8177,6 +8177,125 @@ "serializedGlobalTradeItemNumber": "3014F7110000000000ESIIE3", "type": "ENERGY_SENSORS_INTERFACE", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000DSDPCB": { + "availableFirmwareVersion": "1.0.6", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000DSDPCB", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000042"], + "index": 0, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -58, + "rssiPeerValue": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actionParameter": "NOT_CUSTOMISABLE", + "binaryBehaviorType": "NORMALLY_CLOSE", + "channelRole": "DOOR_BELL_INPUT", + "corrosionPreventionActive": false, + "deviceId": "3014F7110000000000DSDPCB", + "doorBellSensorEventTimestamp": 1673006015756, + "eventDelay": 0, + "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 1, + "label": "", + "multiModeInputMode": "KEY_BEHAVIOR", + "supportedOptionalFeatures": { + "IFeatureAccessAuthorizationSensorChannel": false, + "IFeatureGarageGroupSensorChannel": true, + "IFeatureLightGroupSensorChannel": false, + "IFeatureShadingGroupSensorChannel": false, + "IOptionalFeatureDoorBellSensorEventTimestamp": true, + "IOptionalFeatureEventDelay": false, + "IOptionalFeatureLongPressSupported": false, + "IOptionalFeatureWindowState": false + }, + "windowState": "CLOSED" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000DSDPCB", + "label": "dsdpcb_klingel", + "lastStatusUpdate": 1673006015756, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 410, + "modelType": "HmIP-DSD-PCB", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F7110000000000DSDPCB", + "type": "DOOR_BELL_CONTACT_INTERFACE", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5b4993f7314..5ec37d8d8f5 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -28,7 +28,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 308 + assert len(mock_hap.hmip_device_by_entity_id) == 310 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_event.py b/tests/components/homematicip_cloud/test_event.py new file mode 100644 index 00000000000..de615b35808 --- /dev/null +++ b/tests/components/homematicip_cloud/test_event.py @@ -0,0 +1,37 @@ +"""Tests for the HomematicIP Cloud event.""" + +from homematicip.base.channel_event import ChannelEvent + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, get_and_check_entity_basics + + +async def test_door_bell_event( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, +) -> None: + """Test of door bell event of HmIP-DSD-PCB.""" + entity_id = "event.dsdpcb_klingel_doorbell" + entity_name = "dsdpcb_klingel doorbell" + device_model = "HmIP-DSD-PCB" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["dsdpcb_klingel"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + ch = hmip_device.functionalChannels[1] + channel_event = ChannelEvent( + channelEventType="DOOR_BELL_SENSOR_EVENT", channelIndex=1, deviceId=ch.device.id + ) + + assert ha_state.state == STATE_UNKNOWN + + ch.fire_channel_event(channel_event) + + ha_state = hass.states.get(entity_id) + assert ha_state.state != STATE_UNKNOWN diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index dfd92577a04..b8367f87e57 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,12 +3,18 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import NotFoundError -from homewizard_energy.v1.models import Data, Device, State, System +from homewizard_energy.models import ( + CombinedModels, + Device, + Measurement, + State, + System, + Token, +) import pytest from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @@ -27,7 +33,7 @@ def mock_homewizardenergy( """Return a mock bridge.""" with ( patch( - "homeassistant.components.homewizard.coordinator.HomeWizardEnergyV1", + "homeassistant.components.homewizard.HomeWizardEnergyV1", autospec=True, ) as homewizard, patch( @@ -37,26 +43,84 @@ def mock_homewizardenergy( ): client = homewizard.return_value - client.device.return_value = Device.from_dict( - load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) - ) - client.data.return_value = Data.from_dict( - load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) + ), + state=( + State.from_dict( + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists() + else None + ), ) - if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): - client.state.return_value = State.from_dict( - load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) - ) - else: - client.state.side_effect = NotFoundError + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device - if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): - client.system.return_value = System.from_dict( - load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) - ) - else: - client.system.side_effect = NotFoundError + yield client + + +@pytest.fixture +def mock_homewizardenergy_v2( + device_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with ( + patch( + "homeassistant.components.homewizard.HomeWizardEnergyV2", + autospec=True, + ) as homewizard, + patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergyV2", + new=homewizard, + ), + ): + client = homewizard.return_value + + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture( + f"v2/{device_fixture}/measurement.json", DOMAIN + ) + ), + state=( + State.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() + else None + ), + ) + + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device + + # Authorization flow is used during configuration flow + client.get_token.return_value = Token.from_dict( + load_json_object_fixture("v2/generic/token.json", DOMAIN) + ).token yield client @@ -86,6 +150,20 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_v2() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-BAT_5c2fafabcdef", + ) + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/HWE-BAT/data.json b/tests/components/homewizard/fixtures/HWE-BAT/data.json new file mode 100644 index 00000000000..490120e7ffd --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/data.json @@ -0,0 +1,12 @@ +{ + "wifi_ssid": "simulating v1 support", + "wifi_strength": 100, + "total_power_import_kwh": 123.456, + "total_power_export_kwh": 123.456, + "active_power_w": 123, + "active_voltage_v": 230, + "active_current_a": 1.5, + "active_frequency_hz": 50, + "state_of_charge_pct": 50, + "cycles": 123 +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/device.json b/tests/components/homewizard/fixtures/HWE-BAT/device.json new file mode 100644 index 00000000000..c551dc34c91 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-BAT", + "product_name": "Plug-In Battery", + "serial": "5c2fafabcdef", + "firmware_version": "1.00", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/system.json b/tests/components/homewizard/fixtures/HWE-BAT/system.json new file mode 100644 index 00000000000..b4094f497cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/device.json b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json new file mode 100644 index 00000000000..2dc3f0692a2 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json new file mode 100644 index 00000000000..2004b0cd37f --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json @@ -0,0 +1,48 @@ +{ + "protocol_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "4E6576657220476F6E6E61204C657420596F7520446F776E", + "timestamp": "2024-06-28T14:12:34", + "tariff": 2, + "energy_import_kwh": 13779.338, + "energy_import_t1_kwh": 10830.511, + "energy_import_t2_kwh": 2948.827, + "energy_export_kwh": 1234.567, + "energy_export_t1_kwh": 234.567, + "energy_export_t2_kwh": 1000, + "power_w": -543, + "power_l1_w": -676, + "power_l2_w": 133, + "power_l3_w": 0, + "current_a": 6, + "current_l1_a": -4, + "current_l2_a": 2, + "current_l3_a": 0, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 1, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "average_power_15m_w": 123.0, + "monthly_power_peak_w": 1111.0, + "monthly_power_peak_timestamp": "2024-06-04T10:11:22", + "external": [ + { + "unique_id": "4E6576657220676F6E6E612072756E2061726F756E64", + "type": "gas_meter", + "timestamp": "2024-06-28T14:00:00", + "value": 2569.646, + "unit": "m3" + }, + { + "unique_id": "616E642064657365727420796F75", + "type": "water_meter", + "timestamp": "2024-06-28T14:05:00", + "value": 123.456, + "unit": "m3" + } + ] +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/system.json b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json new file mode 100644 index 00000000000..38bcaeeb584 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/fixtures/v2/generic/token.json b/tests/components/homewizard/fixtures/v2/generic/token.json new file mode 100644 index 00000000000..8fa1e9cb8d1 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/generic/token.json @@ -0,0 +1,4 @@ +{ + "token": "00112233445566778899aabbccddeeff", + "name": "local/new_user" +} diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index 6dd7fcc45d2..16cc62ad726 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index 0a301fc3941..71e70f3a153 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -74,10 +78,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -118,10 +126,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Energy Socket', 'unique_id': 'HWE-SKT_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Energy Socket', 'type': , 'version': 1, @@ -158,10 +170,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index cb5e7ef1f43..2545f674bbd 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,83 +1,176 @@ # serializer version: 1 -# name: test_diagnostics[HWE-KWH1] +# name: test_diagnostics[HWE-BAT] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '1.00', + 'id': '**REDACTED**', + 'model_name': 'Plug-In Battery', + 'product_name': 'Plug-In Battery', + 'product_type': 'HWE-BAT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': 1.5, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': 123, + 'energy_export_kwh': 123.456, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 123.456, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 123.0, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': 50.0, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 230.0, 'wifi_ssid': '**REDACTED**', - 'wifi_strength': 92, + 'wifi_strength': 100, }), + 'state': None, + 'system': dict({ + 'api_v1_enabled': None, + 'cloud_enabled': False, + 'status_led_brightness_pct': 100, + 'uptime_s': 356, + 'wifi_rssi_db': -77, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[HWE-KWH1] + dict({ + 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH1', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', 'product_name': 'kWh meter', 'product_type': 'HWE-KWH1', 'serial': '**REDACTED**', }), + 'measurement': dict({ + 'active_liter_lpm': None, + 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, + 'external_devices': None, + 'frequency_hz': 50.0, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, + 'total_liter_m3': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -91,82 +184,84 @@ # name: test_diagnostics[HWE-KWH3] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'HWE-KWH3', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'HWE-KWH3', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'HWE-KWH3', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -180,133 +275,120 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': -4, - 'active_current_l2_a': 2, - 'active_current_l3_a': 0, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '4.19', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi P1 Meter', + 'product_name': 'P1 meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': 12.345, - 'active_power_average_w': 123.0, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -123, - 'active_power_l2_w': 456, - 'active_power_l3_w': 123.456, - 'active_power_w': -123, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': 2, - 'active_voltage_l1_v': 230.111, - 'active_voltage_l2_v': 230.222, - 'active_voltage_l3_v': 230.333, - 'active_voltage_v': None, 'any_power_fail_count': 4, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': 123.0, + 'current_a': None, + 'current_l1_a': -4.0, + 'current_l2_a': 2.0, + 'current_l3_a': 0.0, + 'cycles': None, + 'energy_export_kwh': 13086.777, + 'energy_export_t1_kwh': 4321.333, + 'energy_export_t2_kwh': 8765.444, + 'energy_export_t3_kwh': 8765.444, + 'energy_export_t4_kwh': 8765.444, + 'energy_import_kwh': 13779.338, + 'energy_import_t1_kwh': 10830.511, + 'energy_import_t2_kwh': 2948.827, + 'energy_import_t3_kwh': 2948.827, + 'energy_import_t4_kwh': 2948.827, 'external_devices': dict({ 'gas_meter_G001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'gas_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 111.111, }), 'heat_meter_H001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'heat_meter', 'unique_id': '**REDACTED**', 'unit': 'GJ', 'value': 444.444, }), 'inlet_heat_meter_IH001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'inlet_heat_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 555.555, }), 'warm_water_meter_WW001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'warm_water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 333.333, }), 'water_meter_W001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), 'timestamp': '2023-01-25T22:09:57', + 'type': 'water_meter', 'unique_id': '**REDACTED**', 'unit': 'm3', 'value': 222.222, }), }), - 'gas_timestamp': '2021-03-14T11:22:33', - 'gas_unique_id': '**REDACTED**', + 'frequency_hz': 50.0, 'long_power_fail_count': 5, 'meter_model': 'ISKRA 2M550T-101', 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', 'monthly_power_peak_w': 1111.0, - 'smr_version': 50, - 'total_energy_export_kwh': 13086.777, - 'total_energy_export_t1_kwh': 4321.333, - 'total_energy_export_t2_kwh': 8765.444, - 'total_energy_export_t3_kwh': 8765.444, - 'total_energy_export_t4_kwh': 8765.444, - 'total_energy_import_kwh': 13779.338, - 'total_energy_import_t1_kwh': 10830.511, - 'total_energy_import_t2_kwh': 2948.827, - 'total_energy_import_t3_kwh': 2948.827, - 'total_energy_import_t4_kwh': 2948.827, - 'total_gas_m3': 1122.333, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -123.0, + 'power_l2_w': 456.0, + 'power_l3_w': 123.456, + 'power_w': -123.0, + 'protocol_version': 50, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': 2, + 'timestamp': None, 'total_liter_m3': 1234.567, - 'unique_meter_id': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'voltage_l1_v': 230.111, + 'voltage_l2_v': 230.222, + 'voltage_l3_v': 230.333, 'voltage_sag_l1_count': 1, 'voltage_sag_l2_count': 2, 'voltage_sag_l3_count': 3, 'voltage_swell_l1_count': 4, 'voltage_swell_l2_count': 5, 'voltage_swell_l3_count': 6, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.19', - 'product': dict({ - 'description': 'The HomeWizard P1 Meter gives you detailed insight in your electricity-, gas consumption and solar surplus.', - 'model': 'HWE-P1', - 'name': 'Wi-Fi P1 Meter', - 'url': 'https://www.homewizard.com/p1-meter/', - }), - 'product_name': 'P1 meter', - 'product_type': 'HWE-P1', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -320,86 +402,88 @@ # name: test_diagnostics[HWE-SKT-11] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '3.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 1457.277, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 1457.277, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 0.0, + 'energy_export_t1_kwh': 0.0, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 63.651, + 'energy_import_t1_kwh': 63.651, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0, - 'total_energy_export_t1_kwh': 0, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 63.651, - 'total_energy_import_t1_kwh': 63.651, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 1457.277, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 1457.277, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 94, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.03', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 94, }), }), 'entry': dict({ @@ -413,86 +497,88 @@ # name: test_diagnostics[HWE-SKT-21] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 666.768, - 'active_current_a': 2.346, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50.005, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '4.07', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Energy Socket', + 'product_name': 'Energy Socket', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.81688, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': 543.312, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': 543.312, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': 123.456, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 231.539, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 666.768, + 'average_power_15m_w': None, + 'current_a': 2.346, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 85.951, + 'energy_export_t1_kwh': 85.951, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 30.511, + 'energy_import_t1_kwh': 30.511, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.005, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 85.951, - 'total_energy_export_t1_kwh': 85.951, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 30.511, - 'total_energy_import_t1_kwh': 30.511, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.81688, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': 543.312, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 543.312, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': 123.456, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 231.539, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 100, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '4.07', - 'product': dict({ - 'description': 'Measure and switch every device.', - 'model': 'HWE-SKT', - 'name': 'Wi-Fi Energy Socket', - 'url': 'https://www.homewizard.com/energy-socket/', - }), - 'product_name': 'Energy Socket', - 'product_type': 'HWE-SKT', - 'serial': '**REDACTED**', - }), 'state': dict({ 'brightness': 255, 'power_on': True, 'switch_lock': False, }), 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': 100.0, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -506,82 +592,84 @@ # name: test_diagnostics[HWE-WTR] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, - 'active_liter_lpm': 0, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': None, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': None, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': None, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '2.03', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi Watermeter', + 'product_name': 'Watermeter', + 'product_type': 'HWE-WTR', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': 0.0, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': None, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': None, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': None, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': None, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': None, - 'total_energy_export_t1_kwh': None, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': None, - 'total_energy_import_t1_kwh': None, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': None, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': 17.014, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 84, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '2.03', - 'product': dict({ - 'description': 'Real-time water consumption insights', - 'model': 'HWE-WTR', - 'name': 'Wi-Fi Watermeter', - 'url': 'https://www.homewizard.com/watermeter/', - }), - 'product_name': 'Watermeter', - 'product_type': 'HWE-WTR', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 84, }), }), 'entry': dict({ @@ -595,82 +683,84 @@ # name: test_diagnostics[SDM230] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': 74.052, - 'active_current_a': 0.273, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': 50, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 1-phase', + 'product_name': 'kWh meter', + 'product_type': 'SDM230-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': 0.611, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': None, - 'active_power_l3_w': None, - 'active_power_w': -1058.296, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': -58.612, - 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, - 'active_voltage_v': 228.472, 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': 74.052, + 'average_power_15m_w': None, + 'current_a': 0.273, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': None, + 'energy_export_kwh': 255.551, + 'energy_export_t1_kwh': 255.551, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 2.705, + 'energy_import_t1_kwh': 2.705, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 50.0, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 255.551, - 'total_energy_export_t1_kwh': 255.551, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 2.705, - 'total_energy_import_t1_kwh': 2.705, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': 0.611, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': -1058.296, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': -1058.296, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': -58.612, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': 228.472, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM230-wifi', - 'name': 'Wi-Fi kWh Meter 1-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'kWh meter', - 'product_type': 'SDM230-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -684,82 +774,84 @@ # name: test_diagnostics[SDM630] dict({ 'data': dict({ - 'data': dict({ - 'active_apparent_power_l1_va': 0, - 'active_apparent_power_l2_va': 3548.879, - 'active_apparent_power_l3_va': 3563.414, - 'active_apparent_power_va': 7112.293, - 'active_current_a': 30.999, - 'active_current_l1_a': 0, - 'active_current_l2_a': 15.521, - 'active_current_l3_a': 15.477, - 'active_frequency_hz': 49.926, + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '3.06', + 'id': '**REDACTED**', + 'model_name': 'Wi-Fi kWh Meter 3-phase', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'SDM630-wifi', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ 'active_liter_lpm': None, - 'active_power_average_w': None, - 'active_power_factor': None, - 'active_power_factor_l1': 1, - 'active_power_factor_l2': 0.999, - 'active_power_factor_l3': 0.997, - 'active_power_l1_w': -1058.296, - 'active_power_l2_w': 158.102, - 'active_power_l3_w': 0.0, - 'active_power_w': -900.194, - 'active_reactive_power_l1_var': 0, - 'active_reactive_power_l2_var': -166.675, - 'active_reactive_power_l3_var': -262.35, - 'active_reactive_power_var': -429.025, - 'active_tariff': None, - 'active_voltage_l1_v': 230.751, - 'active_voltage_l2_v': 228.391, - 'active_voltage_l3_v': 229.612, - 'active_voltage_v': None, 'any_power_fail_count': None, + 'apparent_power_l1_va': 0.0, + 'apparent_power_l2_va': 3548.879, + 'apparent_power_l3_va': 3563.414, + 'apparent_power_va': 7112.293, + 'average_power_15m_w': None, + 'current_a': 30.999, + 'current_l1_a': 0.0, + 'current_l2_a': 15.521, + 'current_l3_a': 15.477, + 'cycles': None, + 'energy_export_kwh': 0.523, + 'energy_export_t1_kwh': 0.523, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 0.101, + 'energy_import_t1_kwh': 0.101, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, 'external_devices': None, - 'gas_timestamp': None, - 'gas_unique_id': None, + 'frequency_hz': 49.926, 'long_power_fail_count': None, 'meter_model': None, 'monthly_power_peak_timestamp': None, 'monthly_power_peak_w': None, - 'smr_version': None, - 'total_energy_export_kwh': 0.523, - 'total_energy_export_t1_kwh': 0.523, - 'total_energy_export_t2_kwh': None, - 'total_energy_export_t3_kwh': None, - 'total_energy_export_t4_kwh': None, - 'total_energy_import_kwh': 0.101, - 'total_energy_import_t1_kwh': 0.101, - 'total_energy_import_t2_kwh': None, - 'total_energy_import_t3_kwh': None, - 'total_energy_import_t4_kwh': None, - 'total_gas_m3': None, + 'power_factor': None, + 'power_factor_l1': 1.0, + 'power_factor_l2': 0.999, + 'power_factor_l3': 0.997, + 'power_l1_w': -1058.296, + 'power_l2_w': 158.102, + 'power_l3_w': 0.0, + 'power_w': -900.194, + 'protocol_version': None, + 'reactive_power_l1_var': 0.0, + 'reactive_power_l2_var': -166.675, + 'reactive_power_l3_var': -262.35, + 'reactive_power_var': -429.025, + 'state_of_charge_pct': None, + 'tariff': None, + 'timestamp': None, 'total_liter_m3': None, - 'unique_meter_id': None, + 'unique_id': None, + 'voltage_l1_v': 230.751, + 'voltage_l2_v': 228.391, + 'voltage_l3_v': 229.612, 'voltage_sag_l1_count': None, 'voltage_sag_l2_count': None, 'voltage_sag_l3_count': None, 'voltage_swell_l1_count': None, 'voltage_swell_l2_count': None, 'voltage_swell_l3_count': None, + 'voltage_v': None, 'wifi_ssid': '**REDACTED**', 'wifi_strength': 92, }), - 'device': dict({ - 'api_version': 'v1', - 'firmware_version': '3.06', - 'product': dict({ - 'description': 'Measure solar panels, car chargers and more.', - 'model': 'SDM630-wifi', - 'name': 'Wi-Fi kWh Meter 3-phase', - 'url': 'https://www.homewizard.com/kwh-meter/', - }), - 'product_name': 'KWh meter 3-phase', - 'product_type': 'SDM630-wifi', - 'serial': '**REDACTED**', - }), 'state': None, 'system': dict({ + 'api_v1_enabled': None, 'cloud_enabled': True, + 'status_led_brightness_pct': None, + 'uptime_s': None, + 'wifi_rssi_db': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index b14028cd97c..1c901bda6f6 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -29,6 +29,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -121,6 +123,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index c5de96cbf8f..f68b5a57d2e 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,8 +1,982 @@ # serializer version: 1 +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_battery_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery cycles', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycles', + 'unique_id': 'HWE-P1_5c2fafabcdef_cycles', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.device_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge_pct', + 'unique_id': 'HWE-P1_5c2fafabcdef_state_of_charge_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Device State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'HWE-P1_5c2fafabcdef_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Device Uptime', + }), + 'context': , + 'entity_id': 'sensor.device_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-28T21:39:04+00:00', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_rssi', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi RSSI', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-77', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'simulating v1 support', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -44,6 +1018,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -90,6 +1065,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -131,6 +1107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -177,6 +1154,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -218,6 +1196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -264,6 +1243,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -305,6 +1285,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -351,6 +1332,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -392,6 +1374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -431,13 +1414,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -479,6 +1463,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -528,6 +1513,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -569,6 +1555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -615,6 +1602,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -656,6 +1644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -702,6 +1691,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -743,6 +1733,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -789,6 +1780,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -828,6 +1820,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -871,6 +1864,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -912,6 +1906,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +1952,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -998,6 +1994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1044,6 +2041,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1085,6 +2083,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1124,13 +2123,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1172,6 +2172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +2219,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1259,6 +2261,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1305,6 +2308,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1346,6 +2350,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1392,6 +2397,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1433,6 +2439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1472,13 +2479,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1520,6 +2528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1566,6 +2575,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1607,6 +2617,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1653,6 +2664,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1694,6 +2706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1740,6 +2753,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1781,6 +2795,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1827,6 +2842,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1868,6 +2884,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1914,6 +2931,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1955,6 +2973,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2004,6 +3023,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2045,6 +3065,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2084,13 +3105,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2132,6 +3154,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2178,6 +3201,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2219,6 +3243,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2265,6 +3290,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2306,6 +3332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2355,6 +3382,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2396,6 +3424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2445,6 +3474,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2486,6 +3516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2535,6 +3566,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2576,6 +3608,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2622,6 +3655,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2663,6 +3697,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2702,13 +3737,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-KWH3-entity_ids8][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2750,6 +3786,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2796,6 +3833,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2837,6 +3875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2883,6 +3922,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2924,6 +3964,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2970,6 +4011,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3011,6 +4053,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3057,6 +4100,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3098,6 +4142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3144,6 +4189,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3183,6 +4229,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3226,6 +4273,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3267,6 +4315,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3312,6 +4361,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3351,6 +4401,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3396,6 +4447,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3437,6 +4489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3476,13 +4529,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3524,6 +4578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3563,13 +4618,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3611,6 +4667,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3650,13 +4707,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3696,6 +4754,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3739,6 +4798,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3780,6 +4840,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3826,6 +4887,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3867,6 +4929,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3913,6 +4976,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3954,6 +5018,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4000,6 +5065,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4041,6 +5107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4087,6 +5154,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4128,6 +5196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4174,6 +5243,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4215,6 +5285,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4261,6 +5332,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4302,6 +5374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4348,6 +5421,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4389,6 +5463,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4435,6 +5510,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4476,6 +5552,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4522,6 +5599,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4563,6 +5641,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4609,6 +5688,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4650,6 +5730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4689,13 +5770,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4735,6 +5817,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4778,6 +5861,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4817,6 +5901,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4862,6 +5947,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4903,6 +5989,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4945,13 +6032,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4991,6 +6079,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5034,6 +6123,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5075,6 +6165,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5117,13 +6208,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5165,6 +6257,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5207,13 +6300,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5255,6 +6349,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5304,6 +6399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5343,6 +6439,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5386,6 +6483,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5425,6 +6523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5468,6 +6567,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5514,6 +6614,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5564,6 +6665,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5605,6 +6707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5651,6 +6754,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5692,6 +6796,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5738,6 +6843,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5779,6 +6885,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5825,6 +6932,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5866,6 +6974,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5912,6 +7021,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5951,6 +7061,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5994,6 +7105,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6033,6 +7145,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6076,6 +7189,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6115,6 +7229,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6158,6 +7273,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6197,6 +7313,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6240,6 +7357,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6279,6 +7397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6322,6 +7441,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6361,6 +7481,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6404,6 +7525,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6445,6 +7567,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6490,6 +7613,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6529,6 +7653,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6572,6 +7697,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6613,6 +7739,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6658,6 +7785,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -6695,6 +7823,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6741,6 +7870,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -6778,6 +7908,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6824,6 +7955,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -6861,6 +7993,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6906,6 +8039,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -6943,6 +8077,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6989,6 +8124,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -7026,6 +8162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7072,6 +8209,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7111,6 +8249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7156,6 +8295,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7197,6 +8337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7236,13 +8377,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-4', + 'state': '-4.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7284,6 +8426,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7323,13 +8466,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7371,6 +8515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7410,13 +8555,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7456,6 +8602,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7499,6 +8646,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7540,6 +8688,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7586,6 +8735,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7627,6 +8777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7673,6 +8824,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7714,6 +8866,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7760,6 +8913,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7801,6 +8955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7847,6 +9002,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7888,6 +9044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7934,6 +9091,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7975,6 +9133,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8021,6 +9180,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8062,6 +9222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8108,6 +9269,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8149,6 +9311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8195,6 +9358,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8236,6 +9400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8282,6 +9447,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8323,6 +9489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8369,6 +9536,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8410,6 +9578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8449,13 +9618,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8495,6 +9665,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8538,6 +9709,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8577,6 +9749,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8622,6 +9795,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8663,6 +9837,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8705,13 +9880,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8751,6 +9927,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8794,6 +9971,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8835,6 +10013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8877,13 +10056,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-123', + 'state': '-123.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8925,6 +10105,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8967,13 +10148,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '456', + 'state': '456.0', }) # --- # name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9015,6 +10197,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9064,6 +10247,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9103,6 +10287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9146,6 +10331,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9185,6 +10371,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9228,6 +10415,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9274,6 +10462,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9324,6 +10513,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9365,6 +10555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9411,6 +10602,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9452,6 +10644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9498,6 +10691,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9539,6 +10733,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9585,6 +10780,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9626,6 +10822,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9672,6 +10869,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9711,6 +10909,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9754,6 +10953,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9793,6 +10993,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9836,6 +11037,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9875,6 +11077,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9918,6 +11121,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9957,6 +11161,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10000,6 +11205,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10039,6 +11245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10082,6 +11289,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10121,6 +11329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10164,6 +11373,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10205,6 +11415,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10250,6 +11461,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10289,6 +11501,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10332,6 +11545,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10373,6 +11587,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10418,6 +11633,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -10455,6 +11671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10501,6 +11718,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -10538,6 +11756,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10584,6 +11803,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -10621,6 +11841,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10666,6 +11887,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -10703,6 +11925,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10749,6 +11972,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -10786,6 +12010,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10832,6 +12057,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10871,6 +12097,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10909,13 +12136,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10957,6 +12185,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10996,13 +12225,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11044,6 +12274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11083,13 +12314,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11131,6 +12363,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11170,13 +12403,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11218,6 +12452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11264,6 +12499,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11305,6 +12541,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11351,6 +12588,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11392,6 +12630,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11438,6 +12677,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11479,6 +12719,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11525,6 +12766,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11566,6 +12808,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11612,6 +12855,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11653,6 +12897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11699,6 +12944,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11740,6 +12986,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11786,6 +13033,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11827,6 +13075,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11873,6 +13122,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11914,6 +13164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11960,6 +13211,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12001,6 +13253,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12047,6 +13300,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12088,6 +13342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12127,13 +13382,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12173,6 +13429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12216,6 +13473,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12257,6 +13515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12306,6 +13565,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12345,6 +13605,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12388,6 +13649,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12429,6 +13691,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12478,6 +13741,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12519,6 +13783,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12568,6 +13833,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12609,6 +13875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12658,6 +13925,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12699,6 +13967,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12745,6 +14014,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12786,6 +14056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12832,6 +14103,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12873,6 +14145,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12919,6 +14192,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12960,6 +14234,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13006,6 +14281,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13045,6 +14321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13088,6 +14365,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13127,6 +14405,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13170,6 +14449,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13209,6 +14489,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13252,6 +14533,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13291,6 +14573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13334,6 +14617,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13373,6 +14657,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13416,6 +14701,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13455,6 +14741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13498,6 +14785,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13539,6 +14827,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13580,10 +14869,183 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13625,6 +15087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13664,13 +15127,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13712,6 +15176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13758,6 +15223,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13799,6 +15265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13848,6 +15315,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13889,6 +15357,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13938,6 +15407,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13977,6 +15447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14020,6 +15491,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14061,6 +15533,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14106,6 +15579,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14147,6 +15621,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14193,6 +15668,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14234,6 +15710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14280,6 +15757,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14321,6 +15799,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14367,6 +15846,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14408,6 +15888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14454,6 +15935,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14495,6 +15977,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14541,6 +16024,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14582,6 +16066,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14631,6 +16116,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14672,6 +16158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14718,6 +16205,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14759,6 +16247,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14808,6 +16297,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14849,6 +16339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14895,6 +16386,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14936,6 +16428,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14982,6 +16475,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15021,6 +16515,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15064,6 +16559,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15105,6 +16601,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15150,6 +16647,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15191,6 +16689,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15237,6 +16736,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15278,6 +16778,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15316,13 +16817,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[HWE-WTR-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15362,6 +16864,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15405,6 +16908,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15446,6 +16950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15491,6 +16996,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15532,6 +17038,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15578,6 +17085,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15619,6 +17127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15665,6 +17174,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15706,6 +17216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15752,6 +17263,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15793,6 +17305,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15839,6 +17352,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15880,6 +17394,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15919,13 +17434,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '50', + 'state': '50.0', }) # --- # name: test_sensors[SDM230-entity_ids5][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15967,6 +17483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16016,6 +17533,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16057,6 +17575,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16103,6 +17622,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16144,6 +17664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16190,6 +17711,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16231,6 +17753,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16277,6 +17800,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16316,6 +17840,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16359,6 +17884,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16400,6 +17926,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16445,6 +17972,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16486,6 +18014,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16532,6 +18061,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16573,6 +18103,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16612,13 +18143,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16660,6 +18192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16706,6 +18239,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16747,6 +18281,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16793,6 +18328,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16834,6 +18370,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16880,6 +18417,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16921,6 +18459,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16960,13 +18499,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17008,6 +18548,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17054,6 +18595,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17095,6 +18637,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17141,6 +18684,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17182,6 +18726,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17228,6 +18773,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17269,6 +18815,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17315,6 +18862,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17356,6 +18904,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17402,6 +18951,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17443,6 +18993,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17492,6 +19043,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17533,6 +19085,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17572,13 +19125,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17620,6 +19174,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17666,6 +19221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17707,6 +19263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17753,6 +19310,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17794,6 +19352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17843,6 +19402,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17884,6 +19444,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17933,6 +19494,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17974,6 +19536,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18023,6 +19586,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18064,6 +19628,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18110,6 +19675,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18151,6 +19717,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18190,13 +19757,14 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensors[SDM630-entity_ids6][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18238,6 +19806,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18284,6 +19853,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18325,6 +19895,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18371,6 +19942,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18412,6 +19984,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18458,6 +20031,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18499,6 +20073,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18545,6 +20120,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18586,6 +20162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18632,6 +20209,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18671,6 +20249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18714,6 +20293,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18755,6 +20335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index c2ef87970f3..cd21cb92819 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -12,13 +12,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -45,10 +46,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -81,7 +83,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -94,13 +96,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,10 +130,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +167,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -177,13 +181,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,10 +215,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -246,7 +252,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -259,13 +265,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -292,10 +299,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -328,7 +336,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -341,13 +349,14 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,10 +383,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -410,7 +420,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on] +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -424,13 +434,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].1 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -457,10 +468,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device-state_set-power_on].2 +# name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -493,7 +505,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -506,13 +518,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,10 +552,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -575,7 +589,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock] +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', @@ -588,13 +602,14 @@ 'state': 'off', }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].1 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -621,10 +636,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state_set-switch_lock].2 +# name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -657,7 +673,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -670,13 +686,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -703,10 +720,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -739,7 +757,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -752,13 +770,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -785,10 +804,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM230-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -821,7 +841,7 @@ 'via_device_id': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled] +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', @@ -834,13 +854,14 @@ 'state': 'on', }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].1 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,10 +888,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[SDM630-switch.device_cloud_connection-system_set-cloud_enabled].2 +# name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 984fda8e7a4..c39853c3f9a 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,18 +1,24 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -54,7 +60,7 @@ async def test_discovery_flow_works( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -100,7 +106,7 @@ async def test_discovery_flow_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -137,7 +143,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -181,7 +187,7 @@ async def test_discovery_disabled_api( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -216,33 +222,7 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - # "api_enabled": "1", --> removed - "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "5c2fafabcdef", - }, - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "invalid_discovery_parameters" - - -async def test_discovery_invalid_api(hass: HomeAssistant) -> None: - """Test discovery detecting invalid_api.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -251,16 +231,16 @@ async def test_discovery_invalid_api(hass: HomeAssistant) -> None: name="", properties={ "api_enabled": "1", - "path": "/api/not_v1", + "path": "/api/v1", "product_name": "P1 meter", - "product_type": "HWE-P1", + # "product_type": "HWE-P1", --> removed "serial": "5c2fafabcdef", }, ), ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" + assert result["reason"] == "invalid_discovery_parameters" async def test_dhcp_discovery_updates_entry( @@ -274,7 +254,7 @@ async def test_dhcp_discovery_updates_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.0.0.127", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -304,7 +284,7 @@ async def test_dhcp_discovery_updates_entry_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.0.0.127", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -326,7 +306,7 @@ async def test_dhcp_discovery_ignores_unknown( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="127.0.0.1", hostname="HW-p1meter-aabbcc", macaddress="5c2fafabcdef", @@ -337,6 +317,32 @@ async def test_dhcp_discovery_ignores_unknown( assert result.get("reason") == "unknown" +async def test_dhcp_discovery_aborts_for_v2_api( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when v2 API is detected. + + DHCP discovery requires authorization which is not yet implemented + """ + mock_homewizardenergy.device.side_effect = UnauthorizedError + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unsupported_api_version" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -350,7 +356,7 @@ async def test_discovery_flow_updates_new_ip( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.0.0.127"), ip_addresses=[ip_address("1.0.0.127")], port=80, @@ -454,12 +460,12 @@ async def test_reauth_flow( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "reauth_enable_api_successful" async def test_reauth_error( @@ -474,7 +480,7 @@ async def test_reauth_error( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -608,3 +614,222 @@ async def test_reconfigure_cannot_connect( # changed entry assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +### TESTS FOR V2 IMPLEMENTATION ### + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_works_with_v2_api_support( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and triggers authorization when detected v2 support.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user authorizing + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_detects_failed_user_authorization( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and detects failed button press by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Restore normal functionality + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_updates_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_handles_user_not_pressing_button( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + assert result["errors"] is None + + # Simulate button not being pressed + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + # Successful reauth + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_with_v2_api_ask_authorization( + hass: HomeAssistant, + # mock_setup_entry: AsyncMock, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test discovery detecting missing discovery info.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=443, + hostname="p1meter-abcdef.local.", + type="", + name="", + properties={ + "api_version": "2.0.0", + "id": "appliance/p1dongle/5c2fafabcdef", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == "cool_token" diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index e3d7f4e6da9..c7063d497c3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -21,6 +21,7 @@ from tests.typing import ClientSessionGenerator "SDM630", "HWE-KWH1", "HWE-KWH3", + "HWE-BAT", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index a01f075ee61..9139ef80d12 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -2,30 +2,85 @@ from datetime import timedelta from unittest.mock import MagicMock +import weakref from freezegun.api import FrozenDateTimeFactory -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, UnauthorizedError import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed -async def test_load_unload( +async def test_load_unload_v1( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homewizardenergy: MagicMock, ) -> None: - """Test loading and unloading of integration.""" + """Test loading and unloading of integration with v1 config.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + weak_ref = weakref.ref(mock_config_entry.runtime_data) + assert weak_ref() is not None + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert weak_ref() is None + + +async def test_load_unload_v2( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config.""" + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy_v2.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED + + +async def test_load_unload_v2_as_v1( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config, but without using it.""" + + # Simulate v2 config but as a P1 Meter + mock_config_entry = MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.device.mock_calls) == 1 + assert len(mock_homewizardenergy.combined.mock_calls) == 1 await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -39,7 +94,7 @@ async def test_load_failed_host_unavailable( mock_homewizardenergy: MagicMock, ) -> None: """Test setup handles unreachable host.""" - mock_homewizardenergy.device.side_effect = TimeoutError() + mock_homewizardenergy.combined.side_effect = TimeoutError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -53,7 +108,7 @@ async def test_load_detect_api_disabled( mock_homewizardenergy: MagicMock, ) -> None: """Test setup detects disabled API.""" - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -64,7 +119,7 @@ async def test_load_detect_api_disabled( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN assert "context" in flow @@ -72,6 +127,31 @@ async def test_load_detect_api_disabled( assert flow["context"].get("entry_id") == mock_config_entry.entry_id +async def test_load_detect_invalid_token( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test setup detects invalid token.""" + mock_homewizardenergy_v2.combined.side_effect = UnauthorizedError() + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm_update_token" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry_v2.entry_id + + @pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( hass: HomeAssistant, @@ -115,7 +195,7 @@ async def test_disablederror_reloads_integration( assert len(flows) == 0 # Simulate DisabledError and wait for next update - mock_homewizardenergy.device.side_effect = DisabledError() + mock_homewizardenergy.combined.side_effect = DisabledError() freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) @@ -128,5 +208,5 @@ async def test_disablederror_reloads_integration( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 623ba018dee..67e51cbafe2 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import CombinedModels, Measurement, State, System import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -44,7 +45,9 @@ async def test_number_entities( # Test unknown handling assert state.state == "100" - mock_homewizardenergy.state.return_value.brightness = None + mock_homewizardenergy.combined.return_value = CombinedModels( + device=None, measurement=Measurement(), system=System(), state=State() + ) async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -53,7 +56,7 @@ async def test_number_entities( assert state.state == STATE_UNKNOWN # Test service methods - assert len(mock_homewizardenergy.state_set.mock_calls) == 0 + assert len(mock_homewizardenergy.state.mock_calls) == 0 await hass.services.async_call( number.DOMAIN, SERVICE_SET_VALUE, @@ -64,10 +67,10 @@ async def test_number_entities( blocking=True, ) - assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=129) + assert len(mock_homewizardenergy.system.mock_calls) == 1 + mock_homewizardenergy.system.assert_called_with(status_led_brightness_pct=50) - mock_homewizardenergy.state_set.side_effect = RequestError + mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, match=r"^An error occurred while communicating with HomeWizard device$", @@ -82,7 +85,7 @@ async def test_number_entities( blocking=True, ) - mock_homewizardenergy.state_set.side_effect = DisabledError + mock_homewizardenergy.system.side_effect = DisabledError with pytest.raises( HomeAssistantError, match=r"^The local API is disabled$", diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py new file mode 100644 index 00000000000..763af48b1a2 --- /dev/null +++ b/tests/components/homewizard/test_repair.py @@ -0,0 +1,86 @@ +"""Test the homewizard config flow.""" + +from unittest.mock import MagicMock, patch + +from homewizard_energy.errors import DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_repair_acquires_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + mock_homewizardenergy_v2: MagicMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair flow is able to obtain and use token.""" + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + client = await hass_client() + + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id="HWE-BAT_5c2fafabcdef" + ) + await hass.async_block_till_done() + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Get active repair flow + issue_id = f"migrate_to_v2_api_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + + assert issue.data.get("entry_id") == mock_config_entry.entry_id + + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user not pressing the button + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config_entry.data[CONF_TOKEN] == "cool_token" + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 60077c2cdf9..fe709570239 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from homewizard_energy.errors import RequestError -from homewizard_energy.v1.models import Data import pytest from syrupy.assertion import SnapshotAssertion @@ -11,7 +10,7 @@ from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -20,6 +19,7 @@ pytestmark = [ ] +@pytest.mark.freeze_time("2025-01-28 21:45:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "entity_ids"), @@ -108,6 +108,8 @@ pytestmark = [ "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", ], ), ( @@ -292,6 +294,22 @@ pytestmark = [ "sensor.water_meter_water", ], ), + ( + "HWE-BAT", + [ + "sensor.device_battery_cycles", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power", + "sensor.device_state_of_charge", + "sensor.device_uptime", + "sensor.device_voltage", + "sensor.device_wi_fi_rssi", + "sensor.device_wi_fi_ssid", + ], + ), ], ) async def test_sensors( @@ -432,6 +450,16 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "HWE-BAT", + [ + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_uptime", + "sensor.device_voltage", + "sensor.device_wi_fi_rssi", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -456,7 +484,7 @@ async def test_sensors_unreachable( assert (state := hass.states.get("sensor.device_energy_import_tariff_1")) assert state.state == "10830.511" - mock_homewizardenergy.data.side_effect = exception + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -464,15 +492,17 @@ async def test_sensors_unreachable( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize("exception", [RequestError]) async def test_external_sensors_unreachable( hass: HomeAssistant, mock_homewizardenergy: MagicMock, + exception: Exception, ) -> None: """Test external device sensor handles API unreachable.""" assert (state := hass.states.get("sensor.gas_meter_gas")) assert state.state == "111.111" - mock_homewizardenergy.data.return_value = Data.from_dict({}) + mock_homewizardenergy.combined.side_effect = exception async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() @@ -491,6 +521,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -520,8 +551,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -533,6 +566,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -542,6 +576,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_2", "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -567,8 +602,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -579,6 +616,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -589,6 +627,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -622,7 +661,9 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -633,6 +674,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -643,6 +685,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -669,8 +712,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -681,12 +726,14 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( "SDM630", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -705,8 +752,10 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -718,6 +767,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -728,6 +778,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -754,8 +805,10 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -766,12 +819,14 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( "HWE-KWH3", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -790,8 +845,10 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -803,6 +860,56 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", + ], + ), + ( + "HWE-BAT", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + "sensor.device_wi_fi_strength", ], ), ], diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index d9f1ac26b4f..ae9b7653b6d 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -86,17 +86,17 @@ async def test_entities_not_created_for_device( @pytest.mark.parametrize( ("device_fixture", "entity_id", "method", "parameter"), [ - ("HWE-SKT-11", "switch.device", "state_set", "power_on"), - ("HWE-SKT-11", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-11", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-SKT-21", "switch.device", "state_set", "power_on"), - ("HWE-SKT-21", "switch.device_switch_lock", "state_set", "switch_lock"), - ("HWE-SKT-21", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-WTR", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH1", "switch.device_cloud_connection", "system_set", "cloud_enabled"), - ("HWE-KWH3", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-SKT-11", "switch.device", "state", "power_on"), + ("HWE-SKT-11", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-11", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-SKT-21", "switch.device", "state", "power_on"), + ("HWE-SKT-21", "switch.device_switch_lock", "state", "switch_lock"), + ("HWE-SKT-21", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-WTR", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM230", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("SDM630", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH1", "switch.device_cloud_connection", "system", "cloud_enabled"), + ("HWE-KWH3", "switch.device_cloud_connection", "system", "cloud_enabled"), ], ) async def test_switch_entities( @@ -200,9 +200,9 @@ async def test_switch_entities( @pytest.mark.parametrize( ("entity_id", "method"), [ - ("switch.device", "state"), - ("switch.device_switch_lock", "state"), - ("switch.device_cloud_connection", "system"), + ("switch.device", "combined"), + ("switch.device_switch_lock", "combined"), + ("switch.device_cloud_connection", "combined"), ], ) async def test_switch_unreachable( diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index f26064b335a..1e9958acb3f 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -12,6 +12,7 @@ ]), 'friendly_name': 'device1', 'humidity': None, + 'hvac_action': , 'hvac_modes': list([ , , diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 57cdfaa9a23..7411a40e74a 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -1200,6 +1200,7 @@ async def test_unique_id( entity_registry: er.EntityRegistry, ) -> None: """Test unique id convert to string.""" + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( Platform.CLIMATE, DOMAIN, diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py index ca0b3da0389..3cde435771e 100644 --- a/tests/components/html5/test_config_flow.py +++ b/tests/components/html5/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.components.html5.issues import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir MOCK_CONF = { ATTR_VAPID_EMAIL: "test@example.com", diff --git a/tests/components/html5/test_init.py b/tests/components/html5/test_init.py index 290cb381296..840890f18d1 100644 --- a/tests/components/html5/test_init.py +++ b/tests/components/html5/test_init.py @@ -1,7 +1,7 @@ """Test the HTML5 setup.""" from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 0d9388907a9..f602a8f3807 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -8,7 +8,7 @@ from unittest.mock import mock_open, patch from aiohttp.hdrs import AUTHORIZATION from aiohttp.test_utils import TestClient -import homeassistant.components.html5.notify as html5 +from homeassistant.components.html5 import notify as html5 from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 052c0031469..e31e630807e 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -192,16 +192,16 @@ async def test_cannot_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_active_access_with_access_token_in_header( @@ -256,16 +256,16 @@ async def test_auth_active_access_with_trusted_ip( for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get("/") - assert ( - resp.status == HTTPStatus.UNAUTHORIZED - ), f"{remote_addr} shouldn't be trusted" + assert resp.status == HTTPStatus.UNAUTHORIZED, ( + f"{remote_addr} shouldn't be trusted" + ) async def test_auth_legacy_support_api_password_cannot_access( diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index a9a147eb17e..f75b0e7f2b0 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -13,7 +13,6 @@ import requests_mock from requests_mock import ANY from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN from homeassistant.const import ( CONF_NAME, @@ -25,6 +24,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -267,8 +278,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "Mock device", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - ssdp.ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + ATTR_UPNP_SERIAL: "00000000", }, { "type": FlowResultType.FORM, @@ -283,8 +294,8 @@ async def test_success(hass: HomeAssistant, login_requests_mock, scheme: str) -> "text": "100002", }, { - ssdp.ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", - # No ssdp.ATTR_UPNP_SERIAL + ATTR_UPNP_FRIENDLY_NAME: "Mobile Wi-Fi", + # No ATTR_UPNP_SERIAL }, { "type": FlowResultType.FORM, @@ -322,18 +333,18 @@ async def test_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="upnp:rootdevice", ssdp_location=f"{url}:60957/rootDesc.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Huawei", - ssdp.ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", - ssdp.ATTR_UPNP_MODEL_NAME: "Huawei router", - ssdp.ATTR_UPNP_MODEL_NUMBER: "12345678", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + ATTR_UPNP_MANUFACTURER: "Huawei", + ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", + ATTR_UPNP_MODEL_NAME: "Huawei router", + ATTR_UPNP_MODEL_NUMBER: "12345678", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", **upnp_data, }, ), diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 692bd1405cf..e4bdda422d1 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -9,12 +9,15 @@ import pytest import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.hue import config_flow, const from homeassistant.components.hue.errors import CannotConnect from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker, ClientError @@ -424,13 +427,13 @@ async def test_bridge_homekit( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -474,13 +477,13 @@ async def test_bridge_homekit_already_configured( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -578,7 +581,7 @@ async def test_bridge_zeroconf( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -614,7 +617,7 @@ async def test_bridge_zeroconf_already_exists( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.217"), ip_addresses=[ip_address("192.168.1.217")], port=443, @@ -639,7 +642,7 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, @@ -687,7 +690,7 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=443, @@ -708,13 +711,13 @@ async def test_bridge_connection_failed( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("0.0.0.0"), ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index c742124e4f0..a9fc1e5c70b 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import color +from homeassistant.util import color as color_util from .conftest import create_config_entry @@ -167,10 +167,10 @@ LIGHT_RAW = { }, "swversion": "66009461", } -LIGHT_GAMUT = color.GamutType( - color.XYPoint(0.704, 0.296), - color.XYPoint(0.2151, 0.7106), - color.XYPoint(0.138, 0.08), +LIGHT_GAMUT = color_util.GamutType( + color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08), ) LIGHT_GAMUT_TYPE = "A" @@ -770,7 +770,7 @@ def test_hs_color() -> None: rooms={}, ) - assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) + assert light.hs_color == color_util.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) async def test_group_features( diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 2b978ffc33f..c831d40d261 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -392,7 +392,7 @@ async def test_light_availability( assert test_light is not None assert test_light.state == "on" - # Change availability by modififying the zigbee_connectivity status + # Change availability by modifying the zigbee_connectivity status for status in ("connectivity_issue", "disconnected", "connected"): mock_bridge_v2.api.emit_event( "update", diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index 388e2f68f99..7b00630f573 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -166,6 +166,7 @@ async def test_group_entity_migration_with_v1_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the legacy style group id as unique id @@ -201,6 +202,7 @@ async def test_group_entity_migration_with_v2_group_id( ) -> None: """Test if entity schema for grouped_lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) # create (deviceless) entity with V1 schema in registry # using the V2 group id as unique id diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 3bb1f8c2551..e1b2b2bff61 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/hunterdouglas_powerview/const.py b/tests/components/hunterdouglas_powerview/const.py index 65b03fd5ec2..2c122ae10f2 100644 --- a/tests/components/hunterdouglas_powerview/const.py +++ b/tests/components/hunterdouglas_powerview/const.py @@ -3,32 +3,36 @@ from ipaddress import IPv4Address from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) MOCK_MAC = "AA::BB::CC::DD::EE::FF" MOCK_SERIAL = "A1B2C3D4E5G6H7" -HOMEKIT_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 2._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -HOMEKIT_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +HOMEKIT_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", name="Powerview Generation 3._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, + properties={ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) -ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN2 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -38,7 +42,7 @@ ZEROCONF_DISCOVERY_GEN2 = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DISCOVERY_GEN3 = ZeroconfServiceInfo( ip_address="1.2.3.4", ip_addresses=[IPv4Address("1.2.3.4")], hostname="mock_hostname", @@ -48,19 +52,19 @@ ZEROCONF_DISCOVERY_GEN3 = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DHCP_DISCOVERY_GEN2 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2 = DhcpServiceInfo( hostname="Powerview Generation 2", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN2_NO_NAME = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN2_NO_NAME = DhcpServiceInfo( hostname="", ip="1.2.3.4", macaddress="aabbccddeeff", ) -DHCP_DISCOVERY_GEN3 = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_GEN3 = DhcpServiceInfo( hostname="Powerview Generation 3", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 42589bb10e0..5a48e08e5db 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -5,12 +5,13 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DHCP_DATA, DISCOVERY_DATA, HOMEKIT_DATA, MOCK_SERIAL @@ -65,7 +66,7 @@ async def test_form_homekit_and_dhcp_cannot_connect( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo, + discovery_info: DhcpServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -112,7 +113,7 @@ async def test_form_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, source: str, - discovery_info: dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo, + discovery_info: DhcpServiceInfo | ZeroconfServiceInfo, api_version: int, ) -> None: """Test we get the form with homekit and dhcp source.""" @@ -166,10 +167,10 @@ async def test_discovered_by_homekit_and_dhcp( hass: HomeAssistant, mock_setup_entry: MagicMock, homekit_source: str, - homekit_discovery: zeroconf.ZeroconfServiceInfo, + homekit_discovery: ZeroconfServiceInfo, api_version: int, dhcp_source: str, - dhcp_discovery: dhcp.DhcpServiceInfo, + dhcp_discovery: DhcpServiceInfo, dhcp_api_version: int, ) -> None: """Test we get the form with homekit and abort for dhcp source when we get both.""" @@ -368,6 +369,7 @@ async def test_migrate_entry( version=1, minor_version=1, ) + entry.add_to_hass(hass) # Add entries with int unique_id entity_registry.async_get_or_create( @@ -387,7 +389,6 @@ async def test_migrate_entry( assert entry.version == 1 assert entry.minor_version == 1 - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 0202cec05b9..49994e4f3ae 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -58,6 +58,15 @@ def mock_values(mower_time_zone) -> dict[str, MowerAttributes]: ) +@pytest.fixture(name="values_one_mower") +def mock_values_one_mower(mower_time_zone) -> dict[str, MowerAttributes]: + """Fixture to set correct scope for the token.""" + return mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower1.json", DOMAIN), + mower_time_zone, + ) + + @pytest.fixture def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -119,3 +128,26 @@ def mock_automower_client(values) -> Generator[AsyncMock]: return_value=mock, ): yield mock + + +@pytest.fixture +def mock_automower_client_one_mower(values) -> Generator[AsyncMock]: + """Mock a Husqvarna Automower client.""" + + async def listen() -> None: + """Mock listen.""" + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + + mock = AsyncMock(spec=AutomowerSession) + mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.get_status.return_value = values + mock.start_listening = AsyncMock(side_effect=listen) + + with patch( + "homeassistant.components.husqvarna_automower.AutomowerSession", + return_value=mock, + ): + yield mock diff --git a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr index 16d9452e847..a077eb134d4 100644 --- a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -238,6 +243,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 2ce3aae3065..088850c1e07 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index 156eee9b8df..e94eea4087c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index a4dc986c2f9..2dab82451a6 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -183,6 +183,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Husqvarna Automower of Erika Mustermann', 'unique_id': '123', 'version': 1, diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 036783dd6d0..1428a75d7b4 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'garden', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index b0ccce5800a..291aef83dbf 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index d57a829a997..02a64718276 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +262,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -455,6 +458,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -504,6 +508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -560,6 +565,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +670,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -711,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -760,6 +769,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -809,6 +819,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -869,6 +880,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -984,6 +997,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1038,6 +1052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1092,6 +1107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1146,6 +1162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1222,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1265,6 +1283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1463,6 +1482,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1666,6 +1686,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1720,6 +1741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1780,6 +1802,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index 8f8f6b367c0..5e01694e924 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 25fa64b531f..5bef810150d 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -3,7 +3,7 @@ import datetime from unittest.mock import AsyncMock, patch -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -69,7 +69,7 @@ async def test_button_states_and_commands( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2023-06-05T00:16:00+00:00" - getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiException( + getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiError( "Test error" ) with pytest.raises( @@ -111,7 +111,7 @@ async def test_sync_clock( await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiException("Test error") + mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ae688571d2c..ec1fb7391b4 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -7,10 +7,10 @@ import time from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ( - ApiException, - AuthException, + ApiError, + AuthError, + HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, - TimeoutException, ) from aioautomower.model import MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory @@ -111,8 +111,8 @@ async def test_expired_token_refresh_failure( @pytest.mark.parametrize( ("exception", "entry_state"), [ - (ApiException, ConfigEntryState.SETUP_RETRY), - (AuthException, ConfigEntryState.SETUP_ERROR), + (ApiError, ConfigEntryState.SETUP_RETRY), + (AuthError, ConfigEntryState.SETUP_ERROR), ], ) async def test_update_failed( @@ -142,7 +142,7 @@ async def test_update_failed( ), ( ["start_listening"], - TimeoutException, + HusqvarnaTimeoutError, "Failed to listen to websocket.", ), ], @@ -227,32 +227,79 @@ async def test_coordinator_automatic_registry_cleanup( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, values: dict[str, MowerAttributes], + freezer: FrozenDateTimeFactory, ) -> None: """Test automatic registry cleanup.""" await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] await hass.async_block_till_done() + # Count current entitties and devices current_entites = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) current_devices = len( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) - - values.pop(TEST_MOWER_ID) + # Remove mower 2 and check if it worked + mower2 = values.pop("1234") mock_automower_client.get_status.return_value = values - await hass.config_entries.async_reload(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) - == current_entites - 37 + == current_entites - 12 ) assert ( len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == current_devices - 1 ) + # Add mower 2 and check if it worked + values["1234"] = mower2 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + + # Remove mower 1 and check if it worked + mower1 = values.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices - 1 + ) + # Add mower 1 and check if it worked + values[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites + ) async def test_add_and_remove_work_area( diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 3aca509e865..044989e5cf0 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerActivities, MowerAttributes, MowerStates from freezegun.api import FrozenDateTimeFactory import pytest @@ -82,7 +82,7 @@ async def test_lawn_mower_commands( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -142,7 +142,7 @@ async def test_lawn_mower_service_commands( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -196,7 +196,7 @@ async def test_lawn_mower_override_work_area_command( getattr( mock_automower_client.commands, aioautomower_command - ).side_effect = ApiException("Test error") + ).side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index e1f232e7b5c..55bf5dda7eb 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -40,7 +40,7 @@ async def test_number_commands( mocked_method = mock_automower_client.commands.set_cutting_height mocked_method.assert_called_once_with(TEST_MOWER_ID, 3) - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -84,7 +84,7 @@ async def test_number_workarea_commands( assert state.state is not None assert state.state == "75" - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py index 18d1b0ed21f..01e7607735b 100644 --- a/tests/components/husqvarna_automower/test_select.py +++ b/tests/components/husqvarna_automower/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import HeadlightModes, MowerAttributes from freezegun.api import FrozenDateTimeFactory import pytest @@ -77,7 +77,7 @@ async def test_select_commands( mocked_method.assert_called_once_with(TEST_MOWER_ID, service.upper()) assert len(mocked_method.mock_calls) == 1 - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 100fd9fe3a4..48903a9630b 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.exceptions import ApiException +from aioautomower.exceptions import ApiError from aioautomower.model import MowerAttributes, MowerModes, Zone from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory @@ -92,7 +92,7 @@ async def test_switch_commands( mocked_method = getattr(mock_automower_client.commands, aioautomower_command) mocked_method.assert_called_once_with(TEST_MOWER_ID) - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -144,12 +144,12 @@ async def test_stay_out_zone_switch_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, boolean) + mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, switch=boolean) state = hass.states.get(entity_id) assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", @@ -207,7 +207,7 @@ async def test_work_area_switch_commands( assert state is not None assert state.state == excepted_state - mocked_method.side_effect = ApiException("Test error") + mocked_method.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index 1cc54020195..b7aa14ef0bf 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 2de7fb1da9a..ad3a97fa6e0 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -63,7 +63,7 @@ def mock_pydrawise( controller_water_use_summary: ControllerWaterUseSummary, ) -> Generator[AsyncMock]: """Mock Hydrawise.""" - with patch("pydrawise.client.Hydrawise", autospec=True) as mock_pydrawise: + with patch("pydrawise.hybrid.HybridClient", autospec=True) as mock_pydrawise: user.controllers = [controller] controller.sensors = sensors mock_pydrawise.return_value.get_user.return_value = user @@ -76,8 +76,8 @@ def mock_pydrawise( @pytest.fixture def mock_auth() -> Generator[AsyncMock]: - """Mock pydrawise Auth.""" - with patch("pydrawise.auth.Auth", autospec=True) as mock_auth: + """Mock pydrawise HybridAuth.""" + with patch("pydrawise.auth.HybridAuth", autospec=True) as mock_auth: yield mock_auth.return_value @@ -215,6 +215,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_USERNAME: "asfd@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "abc123", }, unique_id="hydrawise-customerid", version=1, diff --git a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr index 9886345595d..84e52a7f966 100644 --- a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/snapshots/test_sensor.ambr b/tests/components/hydrawise/snapshots/test_sensor.ambr index dadf3c44789..3e475b1eeb1 100644 --- a/tests/components/hydrawise/snapshots/test_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,6 +168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -220,6 +224,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +330,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -372,6 +379,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +428,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +485,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -525,6 +535,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -573,6 +584,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/snapshots/test_switch.ambr b/tests/components/hydrawise/snapshots/test_switch.ambr index 977bd15f004..9ad37ddbfbf 100644 --- a/tests/components/hydrawise/snapshots/test_switch.ambr +++ b/tests/components/hydrawise/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/snapshots/test_valve.ambr b/tests/components/hydrawise/snapshots/test_valve.ambr index cac08893324..197e7796a07 100644 --- a/tests/components/hydrawise/snapshots/test_valve.ambr +++ b/tests/components/hydrawise/snapshots/test_valve.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index 4d25fd5840b..594286b7f01 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -33,21 +33,26 @@ async def test_form( assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, + { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Hydrawise" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "asdf@asdf.com" + assert result["data"] == { CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", } assert len(mock_setup_entry.mock_calls) == 1 - mock_auth.token.assert_awaited_once_with() + mock_auth.check.assert_awaited_once_with() mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) @@ -60,7 +65,11 @@ async def test_form_api_error( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -69,19 +78,26 @@ async def test_form_api_error( mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_auth_connect_timeout( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: - """Test we handle API errors.""" - mock_auth.token.side_effect = TimeoutError + """Test we handle connection timeout errors.""" + mock_auth.check.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + }, ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -89,9 +105,9 @@ async def test_form_auth_connect_timeout( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "timeout_connect"} - mock_auth.token.reset_mock(side_effect=True) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + mock_auth.check.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_client_connect_timeout( @@ -102,7 +118,11 @@ async def test_form_client_connect_timeout( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -112,29 +132,33 @@ async def test_form_client_connect_timeout( mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_not_authorized_error( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle API errors.""" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) - assert result2["type"] is FlowResultType.CREATE_ENTRY + mock_auth.check.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_reauth( @@ -148,7 +172,9 @@ async def test_reauth( title="Hydrawise", domain=DOMAIN, data={ - CONF_API_KEY: "__api_key__", + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -160,14 +186,62 @@ async def test_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 [result] = flows - assert result["step_id"] == "user" + assert result["step_id"] == "reauth_confirm" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, - ) mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, + ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_fails( + hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User +) -> None: + """Test that the reauth flow handles API errors.""" + mock_config_entry = MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", + }, + unique_id="hydrawise-12345", + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + + mock_auth.check.side_effect = NotAuthorizedError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_auth.check.reset_mock(side_effect=True) + mock_pydrawise.get_user.return_value = user + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 4109fe0f653..ac7e6c25b0d 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, Mock, patch from hyperion import const -from homeassistant.components import ssdp from homeassistant.components.hyperion.const import ( CONF_AUTH_ID, CONF_CREATE_TOKEN, @@ -30,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( TEST_AUTH_REQUIRED_RESP, @@ -67,7 +67,7 @@ TEST_REQUEST_TOKEN_FAIL = { "error": "Token request timeout or denied", } -TEST_SSDP_SERVICE_INFO = ssdp.SsdpServiceInfo( +TEST_SSDP_SERVICE_INFO = SsdpServiceInfo( ssdp_st="upnp:rootdevice", ssdp_location=f"http://{TEST_HOST}:{TEST_PORT_UI}/description.xml", ssdp_usn=f"uuid:{TEST_SYSINFO_ID}", diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index baeed6be1ab..15baac1b055 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -50,6 +50,49 @@ async def test_user_step_success(hass: HomeAssistant, mock_desk_api: MagicMock) assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_replaces_ignored_device( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test user step replaces ignored devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=IDASEN_DISCOVERY_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( diff --git a/tests/components/igloohome/snapshots/test_sensor.ambr b/tests/components/igloohome/snapshots/test_sensor.ambr index f65baa484a0..9e17343d4fa 100644 --- a/tests/components/igloohome/snapshots/test_sensor.ambr +++ b/tests/components/igloohome/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index c26eae28086..2f946459bfe 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py index 06ef7db9f49..6879bc793bb 100644 --- a/tests/components/image/conftest.py +++ b/tests/components/image/conftest.py @@ -7,7 +7,10 @@ import pytest from homeassistant.components import image from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -123,7 +126,7 @@ class MockImageConfigEntry: self, hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test image platform via config entry.""" async_add_entities([self._entities]) diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 3e7c8f2fb91..6ff6d925d7e 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -6,8 +6,7 @@ from unittest.mock import PropertyMock, patch import pytest -from homeassistant.components import http -import homeassistant.components.image_processing as ip +from homeassistant.components import http, image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index d4281b9e513..bdd29f7442b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -171,11 +171,8 @@ async def test_receiving_message_successfully( assert data["subject"] == "Test subject" assert data["uid"] == "1" assert "Test body" in data["text"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) @@ -581,11 +578,8 @@ async def test_reset_last_message( assert data["subject"] == "Test subject" assert data["text"] assert data["initial"] - assert ( - valid_date - and isinstance(data["date"], datetime) - or not valid_date - and data["date"] is None + assert (valid_date and isinstance(data["date"], datetime)) or ( + not valid_date and data["date"] is None ) # Simulate an update where no messages are found (needed for pushed coordinator) @@ -732,9 +726,10 @@ async def test_message_data( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), + ('{{ "body" in text }}', True, None), ("{% bad template }}", None, "Error rendering IMAP custom template"), ], - ids=["subject_test", "sender_filter", "template_error"], + ids=["subject_test", "sender_filter", "body_filter", "template_error"], ) async def test_custom_template( hass: HomeAssistant, diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index 6f23ed3ee80..a10b9b54532 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -16,11 +16,11 @@ HYDROLOGICAL_DATA = HydrologicalData( river="River Name", station_id="123", water_level=SensorData(name="Water Level", value=526.0), - flood_alarm_level=SensorData(name="Flood Alarm Level", value=630.0), - flood_warning_level=SensorData(name="Flood Warning Level", value=590.0), + flood_alarm_level=SensorData(name="Flood Alarm Level", value=None), + flood_warning_level=SensorData(name="Flood Warning Level", value=None), water_temperature=SensorData(name="Water Temperature", value=10.8), - flood_alarm=False, - flood_warning=False, + flood_alarm=None, + flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), ) diff --git a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr b/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr deleted file mode 100644 index c5ae6880022..00000000000 --- a/tests/components/imgw_pib/snapshots/test_binary_sensor.ambr +++ /dev/null @@ -1,97 +0,0 @@ -# serializer version: 1 -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm', - 'unique_id': '123_flood_alarm', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_alarm-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood alarm', - }), - 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_alarm', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning', - 'unique_id': '123_flood_warning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.river_name_station_name_flood_warning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'safety', - 'friendly_name': 'River Name (Station Name) Flood warning', - }), - 'context': , - 'entity_id': 'binary_sensor.river_name_station_name_flood_warning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 494980ba4ce..97453930c1e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -15,22 +15,24 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'River Name (Station Name)', 'unique_id': '123', 'version': 1, }), 'hydrological_data': dict({ - 'flood_alarm': False, + 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', 'unit': None, - 'value': 630.0, + 'value': None, }), - 'flood_warning': False, + 'flood_warning': None, 'flood_warning_level': dict({ 'name': 'Flood Warning Level', 'unit': None, - 'value': 590.0, + 'value': None, }), 'river': 'River Name', 'station': 'Station Name', diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 6c69b890842..ccc6e46befa 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,108 +1,4 @@ # serializer version: 1 -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood alarm level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_alarm_level', - 'unique_id': '123_flood_alarm_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood alarm level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '630.0', - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Flood warning level', - 'platform': 'imgw_pib', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'flood_warning_level', - 'unique_id': '123_flood_warning_level', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.river_name_station_name_flood_warning_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by IMGW-PIB', - 'device_class': 'distance', - 'friendly_name': 'River Name (Station Name) Flood warning level', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.river_name_station_name_flood_warning_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '590.0', - }) -# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -112,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -167,6 +64,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/imgw_pib/test_binary_sensor.py b/tests/components/imgw_pib/test_binary_sensor.py deleted file mode 100644 index 185d4b18575..00000000000 --- a/tests/components/imgw_pib/test_binary_sensor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Test the IMGW-PIB binary sensor platform.""" - -from unittest.mock import AsyncMock, patch - -from freezegun.api import FrozenDateTimeFactory -from imgw_pib import ApiError -from syrupy import SnapshotAssertion - -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform - -ENTITY_ID = "binary_sensor.river_name_station_name_flood_alarm" - - -async def test_binary_sensor( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_imgw_pib_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test states of the binary sensor.""" - with patch("homeassistant.components.imgw_pib.PLATFORMS", [Platform.BINARY_SENSOR]): - await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_availability( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_imgw_pib_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure that we mark the entities unavailable correctly when service is offline.""" - await init_integration(hass, mock_config_entry) - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "off" - - mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") - freezer.tick(UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNAVAILABLE - - mock_imgw_pib_client.get_hydrological_data.side_effect = None - freezer.tick(UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "off" diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index e1b7cda7c88..e352c643676 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -4,9 +4,11 @@ from unittest.mock import AsyncMock, patch from imgw_pib import ApiError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM from homeassistant.components.imgw_pib.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import init_integration @@ -43,3 +45,27 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_remove_binary_sensor_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing a binary_sensor entity.""" + entity_id = "binary_sensor.river_name_station_name_flood_alarm" + mock_config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + BINARY_SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index 276c021fad5..a1920f38006 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -7,7 +7,8 @@ from imgw_pib import ApiError import pytest from syrupy import SnapshotAssertion -from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.components.imgw_pib.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -65,3 +66,27 @@ async def test_availability( assert state assert state.state != STATE_UNAVAILABLE assert state.state == "526.0" + + +async def test_remove_entity( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing entity.""" + entity_id = "sensor.river_name_station_name_flood_alarm_level" + mock_config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + SENSOR_PLATFORM, + DOMAIN, + "123_flood_alarm_level", + suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1], + config_entry=mock_config_entry, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 2df4be2ba7d..4536c64349c 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -664,6 +664,6 @@ async def test_provision_fails_invalid_data( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_improv_data" assert ( - "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + "Received invalid improv via BLE data '000000000000' from device with bluetooth address 'AA:BB:CC:DD:EE:F0'" in caplog.text ) diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py index b00e3a638c8..aacfa886f52 100644 --- a/tests/components/incomfort/conftest.py +++ b/tests/components/incomfort/conftest.py @@ -8,7 +8,6 @@ from incomfortclient import DisplayCode import pytest from homeassistant.components.incomfort.const import DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -19,8 +18,13 @@ MOCK_CONFIG = { "password": "verysecret", } +MOCK_CONFIG_DHCP = { + "username": "admin", + "password": "verysecret", +} + MOCK_HEATER_STATUS = { - "display_code": DisplayCode(126), + "display_code": DisplayCode.STANDBY, "display_text": "standby", "fault_code": None, "is_burning": False, @@ -36,6 +40,23 @@ MOCK_HEATER_STATUS = { "rfstatus_cntr": 0, } +MOCK_HEATER_STATUS_HEATING = { + "display_code": DisplayCode.OPENTHERM, + "display_text": "opentherm", + "fault_code": None, + "is_burning": True, + "is_failed": False, + "is_pumping": True, + "is_tapping": False, + "heater_temp": 35.34, + "tap_temp": 30.21, + "pressure": 1.86, + "serial_no": "c0ffeec0ffee", + "nodenr": 249, + "rf_message_rssi": 30, + "rfstatus_cntr": 0, +} + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -53,12 +74,22 @@ def mock_entry_data() -> dict[str, Any]: return MOCK_CONFIG +@pytest.fixture +def mock_entry_options() -> dict[str, Any] | None: + """Mock config entry options for fixture.""" + return None + + @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_entry_data: dict[str, Any] -) -> ConfigEntry: + hass: HomeAssistant, + mock_entry_data: dict[str, Any], + mock_entry_options: dict[str, Any], +) -> MockConfigEntry: """Mock a config entry setup for incomfort integration.""" - entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data) + entry = MockConfigEntry( + domain=DOMAIN, data=mock_entry_data, options=mock_entry_options + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr index 2f2319b6a44..518ea230705 100644 --- a/tests/components/incomfort/snapshots/test_binary_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -6,11 +6,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -53,11 +54,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -101,11 +103,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -148,11 +151,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -195,11 +199,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -242,11 +247,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -290,11 +296,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -337,11 +344,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -384,11 +392,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -431,11 +440,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -479,11 +489,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -526,11 +537,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -573,11 +585,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -620,11 +633,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -668,11 +682,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -715,11 +730,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, @@ -762,11 +778,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_burner', 'has_entity_name': True, 'hidden_by': None, @@ -809,11 +826,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_fault', 'has_entity_name': True, 'hidden_by': None, @@ -857,11 +875,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_hot_water_tap', 'has_entity_name': True, 'hidden_by': None, @@ -904,11 +923,12 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.boiler_pump', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index 17adcbb3bab..df3fe3f710b 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[legacy-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,11 +12,12 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -38,7 +39,74 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[legacy-override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': , + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[legacy-zero_override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -65,7 +133,7 @@ 'state': 'heat', }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry] +# name: test_setup_platform[modern-override][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -78,11 +146,12 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': None, + 'entity_category': , 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -104,7 +173,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[new_thermostat][climate.thermostat_1-state] +# name: test_setup_platform[modern-override][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, @@ -116,7 +185,74 @@ 'max_temp': 30.0, 'min_temp': 5.0, 'status': dict({ - 'override': 18.0, + 'override': 19.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': , + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[modern-zero_override][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 0.0, 'room_temp': 21.42, 'setpoint': 18.0, }), diff --git a/tests/components/incomfort/snapshots/test_diagnostics.ambr b/tests/components/incomfort/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e7c99f37acd --- /dev/null +++ b/tests/components/incomfort/snapshots/test_diagnostics.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config': dict({ + 'host': '192.168.1.12', + 'password': '**REDACTED**', + 'username': 'admin', + }), + 'gateway': dict({ + 'heater_0': dict({ + 'display_code': 126, + 'display_text': 'standby', + 'fault_code': None, + 'heater_temp': 35.34, + 'is_burning': False, + 'is_failed': False, + 'is_pumping': False, + 'is_tapping': False, + 'nodenr': 249, + 'pressure': 1.86, + 'rf_message_rssi': 30, + 'rfstatus_cntr': 0, + 'rooms': dict({ + '0': dict({ + 'override': 18.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + }), + 'serial_no': 'c0ffeec0ffee', + 'tap_temp': 30.21, + }), + }), + }) +# --- diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr index 8c9ea60f455..294a6094164 100644 --- a/tests/components/incomfort/snapshots/test_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -8,11 +8,12 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_pressure', 'has_entity_name': True, 'hidden_by': None, @@ -59,11 +60,12 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_tap_temperature', 'has_entity_name': True, 'hidden_by': None, @@ -111,11 +113,12 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.boiler_temperature', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr index 06b0d0c1e52..d3fc2b057fc 100644 --- a/tests/components/incomfort/snapshots/test_water_heater.ambr +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -9,11 +9,12 @@ 'min_temp': 30.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'water_heater', - 'entity_category': None, + 'entity_category': , 'entity_id': 'water_heater.boiler', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/incomfort/test_binary_sensor.py b/tests/components/incomfort/test_binary_sensor.py index c724cf4b7b2..e90cc3ac391 100644 --- a/tests/components/incomfort/test_binary_sensor.py +++ b/tests/components/incomfort/test_binary_sensor.py @@ -17,6 +17,7 @@ from tests.common import snapshot_platform @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platform( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -45,6 +46,7 @@ async def test_setup_platform( ids=["is_failed", "is_pumping", "is_burning", "is_tapping"], ) @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_binary_sensors_alt( hass: HomeAssistant, mock_incomfort: MagicMock, diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index ae4c1cf31f7..dbcf14e3bd7 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -1,15 +1,19 @@ """Climate sensor tests for Intergas InComfort integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from syrupy import SnapshotAssertion +from homeassistant.components import climate +from homeassistant.components.incomfort.coordinator import InComfortData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MOCK_HEATER_STATUS, MOCK_HEATER_STATUS_HEATING + from tests.common import snapshot_platform @@ -17,10 +21,15 @@ from tests.common import snapshot_platform @pytest.mark.parametrize( "mock_room_status", [ - {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}, + {"room_temp": 21.42, "setpoint": 18.0, "override": 19.0}, {"room_temp": 21.42, "setpoint": 18.0, "override": 0.0}, ], - ids=["new_thermostat", "legacy_thermostat"], + ids=["override", "zero_override"], +) +@pytest.mark.parametrize( + "mock_entry_options", + [None, {"legacy_setpoint_status": True}], + ids=["modern", "legacy"], ) async def test_setup_platform( hass: HomeAssistant, @@ -31,8 +40,54 @@ async def test_setup_platform( ) -> None: """Test the incomfort entities are set up correctly. - Legacy thermostats report 0.0 as override if no override is set, - but new thermostat sync the override with the actual setpoint instead. + Thermostats report 0.0 as override if no override is set + or when the setpoint has been changed manually, + Some older thermostats do not reset the override setpoint has been changed manually. """ await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("mock_heater_status", "hvac_action"), + [ + (MOCK_HEATER_STATUS.copy(), climate.HVACAction.IDLE), + (MOCK_HEATER_STATUS_HEATING.copy(), climate.HVACAction.HEATING), + ], + ids=["idle", "heating"], +) +async def test_hvac_state( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: ConfigEntry, + hvac_action: climate.HVACAction, +) -> None: + """Test the HVAC state of the thermostat.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + assert state.attributes["hvac_action"] is hvac_action + + +async def test_target_temp( + hass: HomeAssistant, mock_incomfort: MagicMock, mock_config_entry: ConfigEntry +) -> None: + """Test changing the target temperature.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + + incomfort_data: InComfortData = mock_config_entry.runtime_data.incomfort_data + + with patch.object( + incomfort_data.heaters[0].rooms[0], "set_override", AsyncMock() + ) as mock_set_override: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + service_data={ + ATTR_ENTITY_ID: "climate.thermostat_1", + ATTR_TEMPERATURE: 19.0, + }, + ) + mock_set_override.assert_called_once_with(19.0) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index 287fd85715f..e3579182b3d 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -1,21 +1,36 @@ """Tests for the Intergas InComfort config flow.""" -from unittest.mock import AsyncMock, MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError -from incomfortclient import IncomfortError, InvalidHeaterList +from incomfortclient import InvalidGateway, InvalidHeaterList import pytest from homeassistant.components.incomfort.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_CONFIG +from .conftest import MOCK_CONFIG, MOCK_CONFIG_DHCP from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = DhcpServiceInfo( + hostname="rfgateway", + ip="192.168.1.12", + macaddress="0004A3DEADFF", +) + +DHCP_SERVICE_INFO_ALT = DhcpServiceInfo( + hostname="rfgateway", + ip="192.168.1.99", + macaddress="0004A3DEADFF", +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock @@ -38,7 +53,9 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 -async def test_entry_already_configured(hass: HomeAssistant) -> None: +async def test_entry_already_configured( + hass: HomeAssistant, mock_incomfort: MagicMock +) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) entry.add_to_hass(hass) @@ -64,24 +81,22 @@ async def test_entry_already_configured(hass: HomeAssistant) -> None: ("exc", "error", "base"), [ ( - IncomfortError(ClientResponseError(None, None, status=401)), + InvalidGateway, "auth_error", - CONF_PASSWORD, - ), - ( - IncomfortError(ClientResponseError(None, None, status=404)), - "not_found", "base", ), ( - IncomfortError(ClientResponseError(None, None, status=500)), + InvalidHeaterList, + "no_heaters", + "base", + ), + ( + ClientResponseError(None, None, status=500), "unknown", "base", ), - (IncomfortError, "unknown", "base"), - (ValueError, "unknown", "base"), (TimeoutError, "timeout_error", "base"), - (InvalidHeaterList, "no_heaters", "base"), + (ValueError, "unknown", "base"), ], ) async def test_form_validation( @@ -113,3 +128,276 @@ async def test_form_validation( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert "errors" not in result + + +async def test_dhcp_flow_simple( + hass: HomeAssistant, + mock_incomfort: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dhcp flow for older gateway without authentication needed. + + Assert on the creation of the gateway device, climate and boiler devices. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {"host": "192.168.1.12"} + + config_entry: ConfigEntry = result["result"] + entry_id = config_entry.entry_id + + await hass.async_block_till_done(wait_background_tasks=True) + + # Check the gateway device is discovered + gateway_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) + assert gateway_device is not None + assert gateway_device.name == "RFGateway" + assert gateway_device.manufacturer == "Intergas" + assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} + + devices = device_registry.devices.get_devices_for_config_entry_id(entry_id) + assert len(devices) == 3 + boiler_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee")} + ) + assert boiler_device.via_device_id == gateway_device.id + assert boiler_device is not None + climate_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee_1")} + ) + assert climate_device is not None + assert climate_device.via_device_id == gateway_device.id + + # Check the host is dynamically updated + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO_ALT + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_SERVICE_INFO_ALT.ip + + +async def test_dhcp_flow_migrates_existing_entry_without_unique_id( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test dhcp flow migrates an existing entry without unique_id.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Check the gateway device is discovered after a reload + # And has updated connections + gateway_device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert gateway_device is not None + assert gateway_device.name == "RFGateway" + assert gateway_device.manufacturer == "Intergas" + assert gateway_device.connections == {("mac", "00:04:a3:de:ad:ff")} + + devices = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(devices) == 3 + boiler_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee")} + ) + assert boiler_device.via_device_id == gateway_device.id + assert boiler_device is not None + climate_device = device_registry.async_get_device( + identifiers={(DOMAIN, "c0ffeec0ffee_1")} + ) + assert climate_device is not None + assert climate_device.via_device_id == gateway_device.id + + +async def test_dhcp_flow_wih_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test dhcp flow for with authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + + # Try again, but now with the correct host, but still with an auth error + with patch.object( + mock_incomfort(), + "heaters", + side_effect=InvalidGateway, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "192.168.1.12"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_auth" + assert result["errors"] == {"base": "auth_error"} + + # Submit the form with added credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG_DHCP + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-authentication flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=InvalidGateway, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "incorrect-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow succeeds.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_failure( + hass: HomeAssistant, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the re-configure flow fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch.object( + mock_incomfort(), + "heaters", + side_effect=InvalidGateway, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "wrong-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "auth_error"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG | {CONF_PASSWORD: "new-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("user_input", "legacy_setpoint_status"), + [ + ({}, False), + ({"legacy_setpoint_status": False}, False), + ({"legacy_setpoint_status": True}, True), + ], +) +async def test_options_flow( + hass: HomeAssistant, + mock_incomfort: MagicMock, + user_input: dict[str, Any], + legacy_setpoint_status: bool, +) -> None: + """Test options flow.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + with patch("homeassistant.components.incomfort.async_setup_entry") as restart_mock: + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert restart_mock.call_count == 1 + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {"legacy_setpoint_status": legacy_setpoint_status} + assert entry.options.get("legacy_setpoint_status", False) is legacy_setpoint_status diff --git a/tests/components/incomfort/test_diagnostics.py b/tests/components/incomfort/test_diagnostics.py new file mode 100644 index 00000000000..02493681705 --- /dev/null +++ b/tests/components/incomfort/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Test diagnostics for the Intergas InComfort integration.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, SnapshotAssertion +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_incomfort: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the incomfort integration diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + snapshot.assert_match( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + ) diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index 0390a47a616..92ce0afa448 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,33 +1,89 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch -from aiohttp import ClientResponseError +from aiohttp import ClientResponseError, RequestInfo from freezegun.api import FrozenDateTimeFactory -from incomfortclient import IncomfortError +from incomfortclient import InvalidGateway, InvalidHeaterList import pytest +from homeassistant.components.incomfort import DOMAIN from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import async_fire_time_changed +from .conftest import MOCK_HEATER_STATUS + +from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, entity_registry: er.EntityRegistry, - mock_config_entry: ConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test the incomfort integration is set up correctly.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "mock_heater_status", [MOCK_HEATER_STATUS | {"serial_no": "c01d00c0ffee"}] +) +async def test_stale_devices_cleanup( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_heater_status: dict[str, Any], +) -> None: + """Test the incomfort integration is cleaning up stale devices.""" + # Setup an old heater with serial_no c01d00c0ffee + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(mock_config_entry.entry_id) + old_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(old_entries) == 3 + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is not None + assert old_heater.serial_number == "c01d00c0ffee" + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_heater is not None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is not None + + mock_heater_status["serial_no"] = "c0ffeec0ffee" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(new_entries) == 3 + new_heater = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee")}) + assert new_heater is not None + assert new_heater.serial_number == "c0ffeec0ffee" + new_climate = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee_1")}) + assert new_climate is not None + + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -59,12 +115,31 @@ async def test_coordinator_updates( assert state.state == "1.84" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "exc", [ - IncomfortError(ClientResponseError(None, None, status=401)), - IncomfortError(ClientResponseError(None, None, status=500)), - IncomfortError(ValueError("some_error")), + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=401, + ), + InvalidHeaterList, + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ), TimeoutError, ], ) @@ -91,3 +166,60 @@ async def test_coordinator_update_fails( state = hass.states.get("sensor.boiler_pressure") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("exc", "config_entry_state"), + [ + ( + InvalidGateway, + ConfigEntryState.SETUP_ERROR, + ), + (InvalidHeaterList, ConfigEntryState.SETUP_RETRY), + ( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=404, + ), + ConfigEntryState.SETUP_ERROR, + ), + ( + ClientResponseError( + RequestInfo( + url="http://example.com", + method="GET", + headers=[], + real_url="http://example.com", + ), + None, + status=500, + ), + ConfigEntryState.SETUP_RETRY, + ), + (TimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_entry_setup_fails( + hass: HomeAssistant, + mock_incomfort: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: ConfigEntry, + exc: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the incomfort coordinator entry setup fails.""" + with patch( + "homeassistant.components.incomfort.async_connect_gateway", + AsyncMock(side_effect=exc), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("sensor.boiler_pressure") + assert state is None + assert mock_config_entry.state is config_entry_state diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py index d01fd9b403e..df0db39a56c 100644 --- a/tests/components/incomfort/test_sensor.py +++ b/tests/components/incomfort/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -12,6 +13,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.SENSOR]) async def test_setup_platform( hass: HomeAssistant, diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 30ca369672c..01ae0bf8efc 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -22,6 +22,17 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( + name="XXXXcorruptXXXX", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={2096: b"\x0f\x12\x00Z\xc7W\x06"}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + IBBQ_SERVICE_INFO = BluetoothServiceInfo( name="iBBQ", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 154132c34fc..796f57da55b 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -75,6 +75,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=SPS_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.inkbird.config_flow.async_discovered_service_info", + return_value=[SPS_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.inkbird.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "61DE521B-F0BF-9F44-64D4-75BBE1738105"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "IBS-TH 8105" + assert result2["data"] == {} + assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 822136b9021..0f3d6497c2b 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,11 +1,11 @@ """Test the INKBIRD config flow.""" -from homeassistant.components.inkbird.const import DOMAIN +from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -from . import SPS_SERVICE_INFO +from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -34,5 +34,37 @@ async def test_sensors(hass: HomeAssistant) -> None: assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + # Make sure we remember the device type + # in case the name is corrupted later + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: + """Test setting up a known device type with a corrupt name.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, SPS_WITH_CORRUPT_NAME_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_battery") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "87" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Battery" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 31d38a603f1..33e71be6dc2 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from voluptuous_serialize import convert from homeassistant import config_entries -from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( STEP_HUB_V1, STEP_HUB_V2, @@ -20,6 +19,8 @@ from homeassistant.config_entries import ConfigEntryState, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo from .const import ( MOCK_DEVICE, @@ -209,7 +210,7 @@ async def test_form_select_hub_v2(hass: HomeAssistant) -> None: async def test_form_discovery_dhcp(hass: HomeAssistant) -> None: """Test the discovery of the Hub via DHCP.""" - discovery_info = dhcp.DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") + discovery_info = DhcpServiceInfo("1.2.3.4", "", "aabbccddeeff") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery_info ) @@ -270,7 +271,7 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", @@ -302,7 +303,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyINSTEON", pid="AAAA", vid="AAAA", diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 07390cd9571..ba4a6bdf198 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr index 1b85db51d68..afa3c1fa8a9 100644 --- a/tests/components/intellifire/snapshots/test_binary_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -341,6 +348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -437,6 +446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -485,6 +495,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,6 +544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -581,6 +593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -629,6 +642,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +690,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -724,6 +739,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -771,6 +787,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/intellifire/snapshots/test_climate.ambr b/tests/components/intellifire/snapshots/test_climate.ambr index 36f719d2264..d0744424cff 100644 --- a/tests/components/intellifire/snapshots/test_climate.ambr +++ b/tests/components/intellifire/snapshots/test_climate.ambr @@ -14,6 +14,7 @@ 'target_temp_step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr index d749da216ac..548c8d5a8aa 100644 --- a/tests/components/intellifire/snapshots/test_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +204,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -248,6 +253,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +303,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -401,6 +409,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +459,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index f1465c4dcd4..96d0fa17e63 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import AsyncMock from intellifire4py.exceptions import LoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.intellifire.const import CONF_SERIAL, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -166,7 +166,7 @@ async def test_dhcp_discovery_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Test", @@ -196,7 +196,7 @@ async def test_dhcp_discovery_non_intellifire_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="zentrios-Evil", diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py new file mode 100644 index 00000000000..622e55fe24a --- /dev/null +++ b/tests/components/intent/test_temperature.py @@ -0,0 +1,609 @@ +"""Test temperature intents.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import ATTR_DEVICE_CLASS, Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [CLIMATE_DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in bathroom + # nothing in office yet + # nothing in attic yet + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + attic_area = area_registry.async_create(name="Attic") + bathroom_area = area_registry.async_create(name="Bathroom") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # Put areas on different floors: + # first floor => living room and office + # 2nd floor => bedroom + # 3rd floor => attic + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + bathroom_area = area_registry.async_update( + bathroom_area.id, floor_id=second_floor.floor_id + ) + + third_floor = floor_registry.async_create("Third floor") + attic_area = area_registry.async_update( + attic_area.id, floor_id=third_floor.floor_id + ) + + # Add temperature sensors to each area that should *not* be selected + for area in (living_room_area, office_area, bedroom_area, attic_area): + wrong_temperature_entry = entity_registry.async_get_or_create( + "sensor", "test", f"wrong_temperature_{area.id}" + ) + hass.states.async_set( + wrong_temperature_entry.entity_id, + "10.0", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + entity_registry.async_update_entity( + wrong_temperature_entry.entity_id, area_id=area.id + ) + + # Create temperature sensor and assign them to the office/attic + office_temperature_id = "sensor.office_temperature" + attic_temperature_id = "sensor.attic_temperature" + hass.states.async_set( + office_temperature_id, + "15.5", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + office_area = area_registry.async_update( + office_area.id, temperature_entity_id=office_temperature_id + ) + + hass.states.async_set( + attic_temperature_id, + "18.1", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + attic_area = area_registry.async_update( + attic_area.id, temperature_entity_id=attic_temperature_id + ) + + # Multiple climate entities match (error) + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert ( + error.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + ) + + # Select by area (office_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == office_temperature_id + state = response.matched_states[0] + assert state.state == "15.5" + + # Select by preferred area (attic_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": attic_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == attic_temperature_id + state = response.matched_states[0] + assert state.state == "18.1" + + # Select by area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Check area with no climate entities + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bathroom_area.name}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == bathroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Select by floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"floor": {"value": first_floor.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by preferred area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": bedroom_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by preferred floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_floor_id": {"value": first_floor.floor_id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_not_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) + + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py new file mode 100644 index 00000000000..5c08438925e --- /dev/null +++ b/tests/components/iometer/__init__.py @@ -0,0 +1 @@ +"""Tests for the IOmeter integration.""" diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py new file mode 100644 index 00000000000..ee45021952e --- /dev/null +++ b/tests/components/iometer/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the IOmeter tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from iometer import Reading, Status +import pytest + +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.iometer.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_iometer_client() -> Generator[AsyncMock]: + """Mock a new IOmeter client.""" + with ( + patch( + "homeassistant.components.iometer.IOmeterClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.iometer.config_flow.IOmeterClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.host = "10.0.0.2" + client.get_current_reading.return_value = Reading.from_json( + load_fixture("reading.json", DOMAIN) + ) + client.get_current_status.return_value = Status.from_json( + load_fixture("status.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a IOmeter config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="IOmeter-1ISK0000000000", + data={CONF_HOST: "10.0.0.2"}, + unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + ) diff --git a/tests/components/iometer/fixtures/reading.json b/tests/components/iometer/fixtures/reading.json new file mode 100644 index 00000000000..82190c88883 --- /dev/null +++ b/tests/components/iometer/fixtures/reading.json @@ -0,0 +1,14 @@ +{ + "__typename": "iometer.reading.v1", + "meter": { + "number": "1ISK0000000000", + "reading": { + "time": "2024-11-11T11:11:11Z", + "registers": [ + { "obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh" }, + { "obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh" }, + { "obis": "01-00:10.07.00*ff", "value": 100, "unit": "W" } + ] + } + } +} diff --git a/tests/components/iometer/fixtures/status.json b/tests/components/iometer/fixtures/status.json new file mode 100644 index 00000000000..4d3001d8454 --- /dev/null +++ b/tests/components/iometer/fixtures/status.json @@ -0,0 +1,19 @@ +{ + "__typename": "iometer.status.v1", + "meter": { + "number": "1ISK0000000000" + }, + "device": { + "bridge": { "rssi": -30, "version": "build-65" }, + "id": "658c2b34-2017-45f2-a12b-731235f8bb97", + "core": { + "connectionStatus": "connected", + "rssi": -30, + "version": "build-58", + "powerStatus": "battery", + "batteryLevel": 100, + "attachmentStatus": "attached", + "pinStatus": "entered" + } + } +} diff --git a/tests/components/iometer/test_config_flow.py b/tests/components/iometer/test_config_flow.py new file mode 100644 index 00000000000..49fce459282 --- /dev/null +++ b/tests/components/iometer/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the IOmeter config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from iometer import IOmeterConnectionError + +from homeassistant.components import zeroconf +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +IP_ADDRESS = "10.0.0.2" +IOMETER_DEVICE_ID = "658c2b34-2017-45f2-a12b-731235f8bb97" + +ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], + hostname="IOmeter-EC63E8.local.", + name="IOmeter-EC63E8", + port=80, + type="_iometer._tcp.", + properties={}, +) + + +async def test_user_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow_abort_duplicate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow aborts with duplicate.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow error.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_iometer_client.get_current_status.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_abort_duplicate( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/iotty/conftest.py b/tests/components/iotty/conftest.py index 51a23bf18c7..1ce645b402e 100644 --- a/tests/components/iotty/conftest.py +++ b/tests/components/iotty/conftest.py @@ -169,7 +169,7 @@ def mock_iotty() -> Generator[MagicMock]: def mock_coordinator() -> Generator[MagicMock]: """Mock IottyDataUpdateCoordinator.""" with patch( - "homeassistant.components.iotty.coordinator.IottyDataUpdateCoordinator", + "homeassistant.components.iotty.IottyDataUpdateCoordinator", autospec=True, ) as coordinator_mock: yield coordinator_mock diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index c6e8764cf37..16913d340f0 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -15,6 +15,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index ab5998c922f..031ff3c31d4 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -6,6 +6,7 @@ from pyipma.forecast import Forecast, Forecast_Location, Weather_Type from pyipma.observation import Observation from pyipma.rcm import RCM from pyipma.uv import UV +from pyipma.warnings import Warning from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME @@ -20,6 +21,20 @@ ENTRY_CONFIG = { class MockLocation: """Mock Location from pyipma.""" + async def warnings(self, api): + """Mock Warnings.""" + return [ + Warning( + text="Na costa Sul, ondas de sueste com 2 a 2,5 metros, em especial " + "no barlavento.", + awarenessTypeName="Agitação Marítima", + idAreaAviso="FAR", + startTime=datetime(2024, 12, 26, 12, 24), + awarenessLevelID="yellow", + endTime=datetime(2024, 12, 28, 6, 0), + ) + ] + async def fire_risk(self, api): """Mock Fire Risk.""" return RCM("some place", 3, (0, 0)) diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index adff8206add..455a85002d3 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -35,3 +35,19 @@ async def test_ipma_uv_index_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.hometown_uv_index") assert state.state == "6" + + +async def test_ipma_warning_create_sensors(hass: HomeAssistant) -> None: + """Test creation of warning sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_weather_alert") + + assert state.state == "yellow" + + assert state.attributes["awarenessTypeName"] == "Agitação Marítima" diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index ca374bd7e5e..89b54e22bbb 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -2,9 +2,9 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ATTR_HOSTNAME = "hostname" ATTR_PROPERTIES = "properties" @@ -30,7 +30,7 @@ MOCK_USER_INPUT = { CONF_BASE_PATH: BASE_PATH, } -MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPP_SERVICE_INFO = ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -40,7 +40,7 @@ MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( properties={"rp": ZEROCONF_RP}, ) -MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_IPPS_SERVICE_INFO = ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/ipp/snapshots/test_sensor.ambr b/tests/components/ipp/snapshots/test_sensor.ambr index 3f910399ad8..f8e0578a6b9 100644 --- a/tests/components/ipp/snapshots/test_sensor.ambr +++ b/tests/components/ipp/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -73,6 +74,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -232,6 +236,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -283,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,6 +338,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index f2fa656cb0f..41cfedb0e29 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -358,6 +358,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index f14043c096e..63c7d129987 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -24,6 +24,7 @@ from pynecil import ( import pytest from homeassistant.components.iron_os import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from tests.common import MockConfigEntry @@ -110,6 +111,19 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_ignored") +def mock_config_entry_ignored() -> MockConfigEntry: + """Mock Pinecil configuration entry for ignored device.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={}, + unique_id="c0:ff:ee:c0:ff:ee", + entry_id="1234567890", + source=SOURCE_IGNORE, + ) + + @pytest.fixture(name="ble_device") def mock_ble_device() -> Generator[MagicMock]: """Mock BLEDevice.""" diff --git a/tests/components/iron_os/snapshots/test_binary_sensor.ambr b/tests/components/iron_os/snapshots/test_binary_sensor.ambr index 17b49c1d687..c36c1cc42ff 100644 --- a/tests/components/iron_os/snapshots/test_binary_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_button.ambr b/tests/components/iron_os/snapshots/test_button.ambr index 64a71f5e424..c9ff9181515 100644 --- a/tests/components/iron_os/snapshots/test_button.ambr +++ b/tests/components/iron_os/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f8db1262254 --- /dev/null +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry_data': dict({ + 'address': 'c0:ff:ee:c0:ff:ee', + }), + 'device_info': dict({ + '__type': "", + 'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + }), + 'live_data': dict({ + '__type': "", + 'repr': 'LiveDataResponse(live_temp=298, setpoint_temp=300, dc_voltage=20.6, handle_temp=36.3, pwm_level=41, power_src=, tip_resistance=6.2, uptime=1671, movement_time=10000, max_tip_temp_ability=460, tip_voltage=2212, hall_sensor=0, operating_mode=, estimated_power=24.8)', + }), + 'settings_data': dict({ + }), + }) +# --- diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index fc4fe96d746..62fcd120201 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,6 +238,7 @@ 'step': 2.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +295,7 @@ 'step': 250, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -346,6 +352,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,6 +409,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +466,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -514,6 +523,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +579,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -626,6 +637,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -682,6 +694,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -739,6 +752,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -796,6 +810,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -852,6 +867,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -909,6 +925,7 @@ 'step': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -966,6 +983,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1040,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index ce6045c1243..10aacc838df 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,6 +76,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -136,6 +138,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +196,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -237,6 +241,62 @@ 'state': 'right_handed', }) # --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power Delivery 3.1 EPR', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_usb_pd_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_power_delivery_3_1_epr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Power Delivery 3.1 EPR', + 'options': list([ + 'off', + 'on', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_power_delivery_3_1_epr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_state[select.pinecil_power_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -252,6 +312,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -310,6 +371,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -367,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -424,6 +487,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr index 0eb8e81fb4f..6a30aa6632b 100644 --- a/tests/components/iron_os/snapshots/test_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +214,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -325,6 +331,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -391,6 +398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +458,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -505,6 +514,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -559,6 +569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -609,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -660,6 +672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index f13cdcfe666..a3d28e58d63 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index e0872d032ec..f2db3246158 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -9,6 +9,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/test_config_flow.py b/tests/components/iron_os/test_config_flow.py index e1ac8fb9f00..88bef117c26 100644 --- a/tests/components/iron_os/test_config_flow.py +++ b/tests/components/iron_os/test_config_flow.py @@ -106,3 +106,28 @@ async def test_async_step_bluetooth_devices_already_setup( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("discovery") +async def test_async_step_user_setup_replaces_igonored_device( + hass: HomeAssistant, config_entry_ignored: AsyncMock +) -> None: + """Test the user initiated form can replace an ignored device.""" + + config_entry_ignored.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" diff --git a/tests/components/iron_os/test_diagnostics.py b/tests/components/iron_os/test_diagnostics.py new file mode 100644 index 00000000000..05f627e6bc6 --- /dev/null +++ b/tests/components/iron_os/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for IronOS diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/iron_os/test_select.py b/tests/components/iron_os/test_select.py index cfd4d8ecbb1..8cc848dd4cb 100644 --- a/tests/components/iron_os/test_select.py +++ b/tests/components/iron_os/test_select.py @@ -16,6 +16,7 @@ from pynecil import ( ScreenOrientationMode, ScrollSpeed, TempUnit, + USBPDMode, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -105,6 +106,11 @@ async def test_state( "loop", (CharSetting.LOGO_DURATION, LogoDuration.LOOP), ), + ( + "select.pinecil_power_delivery_3_1_epr", + "on", + (CharSetting.USB_PD_MODE, USBPDMode.ON), + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 522006b0847..90a3a90c451 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -3,7 +3,7 @@ from datetime import datetime from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util MOCK_USER_INPUT = { CONF_NAME: "Home", diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 7961b79676b..5ae11d8f850 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import NOW, PRAYER_TIMES diff --git a/tests/components/israel_rail/snapshots/test_sensor.ambr b/tests/components/israel_rail/snapshots/test_sensor.ambr index f851f1cd726..610c2c53e22 100644 --- a/tests/components/israel_rail/snapshots/test_sensor.ambr +++ b/tests/components/israel_rail/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -197,6 +201,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -244,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index c84d55c059c..7329eec7f70 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), diff --git a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr index b5056019c74..296ce26c7f2 100644 --- a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -115,6 +117,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -169,6 +172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -223,6 +227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -277,6 +282,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -331,6 +337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -385,6 +392,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -439,6 +447,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +501,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -546,6 +556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -600,6 +611,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -654,6 +666,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +721,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -762,6 +776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -816,6 +831,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ista_ecotrend/test_statistics.py b/tests/components/ista_ecotrend/test_statistics.py index 21877f686df..aa4f71037c4 100644 --- a/tests/components/ista_ecotrend/test_statistics.py +++ b/tests/components/ista_ecotrend/test_statistics.py @@ -41,7 +41,7 @@ async def test_statistics_import( # Test that consumption statistics for 2 months have been added for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, @@ -70,7 +70,7 @@ async def test_statistics_import( await async_wait_recording_done(hass) for entity in entities: - statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix("sensor.")}" + statistic_id = f"ista_ecotrend:{entity.entity_id.removeprefix('sensor.')}" stats = await hass.async_add_executor_job( statistics_during_period, hass, diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index 2bc1fff222f..3a688f450d0 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -7,7 +7,6 @@ from pyisy import ISYConnectionError, ISYInvalidAuthError import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.isy994.const import ( CONF_TLS_VER, DOMAIN, @@ -18,6 +17,12 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IGNORE, SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -255,13 +260,13 @@ async def test_form_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -274,13 +279,13 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -322,13 +327,13 @@ async def test_form_ssdp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -353,13 +358,13 @@ async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -386,13 +391,13 @@ async def test_form_ssdp_existing_entry_with_alternate_port( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -417,13 +422,13 @@ async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"https://3.3.3.3/{ISY_URL_POSTFIX}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", - ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + ATTR_UPNP_FRIENDLY_NAME: "myisy", + ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", }, ), ) @@ -440,7 +445,7 @@ async def test_form_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -476,7 +481,7 @@ async def test_form_dhcp_with_polisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="polisy", macaddress=MOCK_POLISY_MAC, @@ -516,7 +521,7 @@ async def test_form_dhcp_with_eisy(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="eisy", macaddress=MOCK_MAC, @@ -564,7 +569,7 @@ async def test_form_dhcp_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -594,7 +599,7 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, @@ -620,7 +625,7 @@ async def test_form_dhcp_existing_ignored_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", hostname="isy994-ems", macaddress=MOCK_MAC, diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index 3b650f7927f..e73f0cfee24 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index 1e64ef9e850..b97aef6027b 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index c1512de912f..f96190fdbc2 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +204,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -251,6 +256,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index 440bffc2256..ba0a2b4835e 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from freezegun import freeze_time as alter_time # noqa: F401 from homeassistant.components import jewish_calendar -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 8abaaecb77d..5cfaaedfc72 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 4897ef7749b..aac0f583b05 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/kaleidescape/__init__.py b/tests/components/kaleidescape/__init__.py index 8182cb73743..6700218f8d4 100644 --- a/tests/components/kaleidescape/__init__.py +++ b/tests/components/kaleidescape/__init__.py @@ -1,13 +1,16 @@ """Tests for Kaleidescape integration.""" -from homeassistant.components import ssdp -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) MOCK_HOST = "127.0.0.1" MOCK_SERIAL = "123456" MOCK_NAME = "Theater" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{MOCK_HOST}", diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index 8ca91d00386..dc0c89e8ea6 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -1,6 +1,5 @@ """Tests for the Keenetic NDMS2 component.""" -from homeassistant.components import ssdp from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import ( CONF_HOST, @@ -9,6 +8,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_NAME = "Keenetic Ultra 2030" MOCK_IP = "0.0.0.0" @@ -30,12 +34,12 @@ MOCK_OPTIONS = { const.CONF_INTERFACES: ["Home", "VPS0"], } -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, upnp={ - ssdp.ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - ssdp.ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, + ATTR_UPNP_UDN: "uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ATTR_UPNP_FRIENDLY_NAME: MOCK_NAME, }, ) diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 18bacc3a32c..7ddcdf38ed6 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -8,11 +8,15 @@ from ndms2_client.client import InterfaceInfo, RouterInfo import pytest from homeassistant import config_entries -from homeassistant.components import keenetic_ndms2 as keenetic, ssdp +from homeassistant.components import keenetic_ndms2 as keenetic from homeassistant.components.keenetic_ndms2 import const from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS, MOCK_SSDP_DISCOVERY_INFO @@ -200,7 +204,7 @@ async def test_ssdp_ignored(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=keenetic.DOMAIN, source=config_entries.SOURCE_IGNORE, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -222,7 +226,7 @@ async def test_ssdp_update_host(hass: HomeAssistant) -> None: domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS, - unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN], + unique_id=MOCK_SSDP_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN], ) entry.add_to_hass(hass) @@ -247,7 +251,7 @@ async def test_ssdp_reject_no_udn(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp.pop(ssdp.ATTR_UPNP_UDN) + discovery_info.upnp.pop(ATTR_UPNP_UDN) result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, @@ -264,7 +268,7 @@ async def test_ssdp_reject_non_keenetic(hass: HomeAssistant) -> None: discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) discovery_info.upnp = {**discovery_info.upnp} - discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" + discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME] = "Suspicious device" result = await hass.config_entries.flow.async_init( keenetic.DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_SSDP}, diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py index ff3b28617d3..ad443fa3154 100644 --- a/tests/components/kira/test_remote.py +++ b/tests/components/kira/test_remote.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock from homeassistant.components.kira import remote as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + SERVICE_SEND_COMMAND = "send_command" TEST_CONFIG = {kira.DOMAIN: {"devices": [{"host": "127.0.0.1", "port": 17324}]}} @@ -28,6 +30,8 @@ def test_service_call(hass: HomeAssistant) -> None: kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 remote = DEVICES[0] + remote.hass = hass + remote.platform = MockEntityPlatform(hass) assert remote.name == "kira" diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py index fe0fc95a918..3bd46f18765 100644 --- a/tests/components/kira/test_sensor.py +++ b/tests/components/kira/test_sensor.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch from homeassistant.components.kira import sensor as kira from homeassistant.core import HomeAssistant +from tests.common import MockEntityPlatform + TEST_CONFIG = {kira.DOMAIN: {"sensors": [{"host": "127.0.0.1", "port": 17324}]}} DISCOVERY_INFO = {"name": "kira", "device": "kira"} @@ -29,6 +31,8 @@ def test_kira_sensor_callback( kira.setup_platform(hass, TEST_CONFIG, add_entities, DISCOVERY_INFO) assert len(DEVICES) == 1 sensor = DEVICES[0] + sensor.hass = hass + sensor.platform = MockEntityPlatform(hass) assert sensor.name == "kira" diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr new file mode 100644 index 00000000000..b91131eb2b0 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_statistics_issues + dict({ + 'sensor.statistics_issues_issue_1': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_1', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_2': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'cats', + 'state_unit': 'dogs', + 'statistic_id': 'sensor.statistics_issues_issue_2', + 'supported_unit': 'cats', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_3': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_3', + }), + 'type': 'state_class_removed', + }), + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_3', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_4': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_4', + }), + 'type': 'no_state', + }), + ]), + }) +# --- diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index bbf88c84eca..7b433c40170 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -69,3 +69,84 @@ }), }) # --- +# name: test_states_with_subentry + set({ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Outlet 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Outlet 2 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_2_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor test', + }), + 'context': , + 'entity_id': 'sensor.sensor_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 2', + 'state_class': , + 'unit_of_measurement': 'dogs', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), + }) +# --- diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index fe4311ad711..5535554017f 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -81,6 +83,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -129,6 +132,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +163,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -191,6 +196,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 9e46845e1cb..933979ee913 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator from io import StringIO -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest @@ -15,6 +15,7 @@ from homeassistant.components.backup import ( from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -35,7 +36,8 @@ async def backup_only() -> AsyncGenerator[None]: @pytest.fixture(autouse=True) async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: - """Set up Kitchen Sink integration.""" + """Set up Kitchen Sink and backup integrations.""" + async_initialize_backup(hass) with patch("homeassistant.components.backup.is_hassio", return_value=False): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -55,7 +57,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } config_entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -66,7 +71,9 @@ async def test_agents_info( response = await client.receive_json() assert response["success"] - assert response["result"] == {"agents": [{"agent_id": "backup.local"}]} + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -76,7 +83,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } @@ -94,17 +104,16 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 1234}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00Z", + "extra_metadata": {}, "failed_agent_ids": [], "folders": ["media", "share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Kitchen sink syncer", - "protected": False, - "size": 1234, "with_automatic_settings": None, } ] @@ -177,17 +186,16 @@ async def test_agents_upload( assert len(backup_list) == 2 assert backup_list[1] == { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 0.0}}, "backup_id": "test-backup", "database_included": True, "date": "1970-01-01T00:00:00.000Z", + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": False}, "failed_agent_ids": [], "folders": ["media", "share"], "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0.0, "with_automatic_settings": False, } diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 5f163d1342e..1eea1c8036b 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -104,3 +104,85 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert config_entry.options == {"section_1": {"bool": True, "int": 15}} await hass.async_block_till_done() + + +@pytest.mark.usefixtures("no_platforms") +async def test_subentry_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "entity"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_sensor" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Sensor 1", "state": 15}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"state": 15}, + subentry_id=subentry_id, + subentry_type="entity", + title="Sensor 1", + unique_id=None, + ) + } + + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("no_platforms") +async def test_subentry_reconfigure_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + subentry_id = "mock_id" + config_entry = MockConfigEntry( + domain=DOMAIN, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"state": 15}, + subentry_id="mock_id", + subentry_type="entity", + title="Sensor 1", + unique_id=None, + ) + ], + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await config_entry.start_subentry_reconfigure_flow( + hass, "entity", subentry_id + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_sensor" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Renamed sensor 1", "state": 5}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"state": 5}, + subentry_id=subentry_id, + subentry_type="entity", + title="Renamed sensor 1", + unique_id=None, + ) + } + + await hass.async_block_till_done() diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index b832577a48a..50518f89107 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from unittest.mock import ANY import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN @@ -17,7 +18,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -102,6 +103,25 @@ async def test_demo_statistics_growth(hass: HomeAssistant) -> None: assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) +@pytest.mark.usefixtures("recorder_mock", "mock_history") +async def test_statistics_issues( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that the kitchen sink sum statistics causes statistics issues.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_start() + await async_wait_recording_done(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "recorder/validate_statistics"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + @pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("mock_history") async def test_issues_created( diff --git a/tests/components/kitchen_sink/test_sensor.py b/tests/components/kitchen_sink/test_sensor.py index c4b5f03499e..f980e39f652 100644 --- a/tests/components/kitchen_sink/test_sensor.py +++ b/tests/components/kitchen_sink/test_sensor.py @@ -5,11 +5,14 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant import config_entries from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + @pytest.fixture async def sensor_only() -> None: @@ -21,14 +24,41 @@ async def sensor_only() -> None: yield -@pytest.fixture(autouse=True) +@pytest.fixture async def setup_comp(hass: HomeAssistant, sensor_only): """Set up demo component.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() +@pytest.mark.usefixtures("setup_comp") async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test the expected sensor entities are added.""" states = hass.states.async_all() assert set(states) == snapshot + + +@pytest.mark.usefixtures("sensor_only") +async def test_states_with_subentry( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the expected sensor entities are added.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"state": 15}, + subentry_id="blabla", + subentry_type="entity", + title="Sensor test", + unique_id=None, + ) + ], + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + states = hass.states.async_all() + assert set(states) == snapshot diff --git a/tests/components/knocki/snapshots/test_event.ambr b/tests/components/knocki/snapshots/test_event.ambr index fba1c90b45d..65fecd59739 100644 --- a/tests/components/knocki/snapshots/test_event.ambr +++ b/tests/components/knocki/snapshots/test_event.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index ef8398b3d17..71218010b45 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -3,17 +3,22 @@ A KNXTestKit instance can be requested from a fixture. It provides convenience methods to test outgoing KNX telegrams and inject incoming telegrams. To test something add a test function requesting the `hass` and `knx` fixture and -set up the KNX integration by passing a KNX config dict to `knx.setup_integration`. +set up the KNX integration with `knx.setup_integration`. +You can pass a KNX YAML-config dict or a ConfigStore fixture filename to the setup method. The fixture should be placed in the `tests/components/knx/fixtures` directory. ```python -async def test_something(hass, knx): - await knx.setup_integration({ +async def test_some_yaml(hass: HomeAssistant, knx: KNXTestKit): + await knx.setup_integration( + yaml_config={ "switch": { "name": "test_switch", "address": "1/2/3", } } ) + +async def test_some_config_store(hass: HomeAssistant, knx: KNXTestKit): + await knx.setup_integration(config_store_fixture="config_store_filename.json") ``` ## Asserting outgoing telegrams diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index c0ec1dd9b9a..c9092a1774f 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -44,7 +44,6 @@ from tests.common import MockConfigEntry, load_json_object_fixture from tests.typing import WebSocketGenerator FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", KNX_DOMAIN) -FIXTURE_CONFIG_STORAGE_DATA = load_json_object_fixture("config_store.json", KNX_DOMAIN) class KNXTestKit: @@ -52,10 +51,16 @@ class KNXTestKit: INDIVIDUAL_ADDRESS = "1.2.3" - def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], + ) -> None: """Init KNX test helper class.""" self.hass: HomeAssistant = hass self.mock_config_entry: MockConfigEntry = mock_config_entry + self.hass_storage: dict[str, Any] = hass_storage self.xknx: XKNX # outgoing telegrams will be put in the List instead of sent to the interface # telegrams to an InternalGroupAddress won't be queued here @@ -69,7 +74,10 @@ class KNXTestKit: assert test_state.attributes.get(attribute) == value async def setup_integration( - self, config: ConfigType, add_entry_to_hass: bool = True + self, + yaml_config: ConfigType | None = None, + config_store_fixture: str | None = None, + add_entry_to_hass: bool = True, ) -> None: """Create the KNX integration.""" @@ -101,15 +109,21 @@ class KNXTestKit: self.xknx = args[0] return DEFAULT + if config_store_fixture: + self.hass_storage[KNX_CONFIG_STORAGE_KEY] = load_json_object_fixture( + config_store_fixture, KNX_DOMAIN + ) + if add_entry_to_hass: self.mock_config_entry.add_to_hass(self.hass) + knx_config = {KNX_DOMAIN: yaml_config or {}} with patch( "xknx.xknx.knx_interface_factory", return_value=knx_ip_interface_mock(), side_effect=fish_xknx, ): - await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) + await async_setup_component(self.hass, KNX_DOMAIN, knx_config) await self.hass.async_block_till_done() ######################## @@ -174,12 +188,12 @@ class KNXTestKit: ) telegram = self._outgoing_telegrams.pop(0) - assert isinstance( - telegram.payload, apci_type - ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" - assert ( - telegram.destination_address == _expected_ga - ), f"Group address mismatch in {telegram} - Expected: {group_address}" + assert isinstance(telegram.payload, apci_type), ( + f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" + ) + assert telegram.destination_address == _expected_ga, ( + f"Group address mismatch in {telegram} - Expected: {group_address}" + ) if payload is not None: assert ( telegram.payload.value.value == payload # type: ignore[attr-defined] @@ -306,9 +320,13 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -async def knx(hass: HomeAssistant, mock_config_entry: MockConfigEntry): +async def knx( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +): """Create a KNX TestKit instance.""" - knx_test_kit = KNXTestKit(hass, mock_config_entry) + knx_test_kit = KNXTestKit(hass, mock_config_entry, hass_storage) yield knx_test_kit await knx_test_kit.assert_no_telegram() @@ -322,12 +340,6 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: } -@pytest.fixture -def load_config_store(hass_storage: dict[str, Any]) -> None: - """Mock KNX config store data.""" - hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA - - @pytest.fixture async def create_ui_entity( hass: HomeAssistant, @@ -335,7 +347,7 @@ async def create_ui_entity( hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], ) -> KnxEntityGenerator: - """Return a helper to create a KNX entities via WS. + """Return a helper to create KNX entities via WS. The KNX integration must be set up before using the helper. """ diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json new file mode 100644 index 00000000000..427867cff8c --- /dev/null +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": {}, + "binary_sensor": { + "knx_es_01JJP1XDQRXB0W6YYGXW6Y1X10": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_sensor": { + "state": "3/2/21", + "passive": [] + }, + "respond_to_read": false, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store_light_switch.json similarity index 100% rename from tests/components/knx/fixtures/config_store.json rename to tests/components/knx/fixtures/config_store_light_switch.json diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index dbb8d2ee832..b93b7e965df 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test KNX binary sensor.""" from datetime import timedelta +from typing import Any from freezegun.api import FrozenDateTimeFactory +import pytest -from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, +) from homeassistant.components.knx.schema import BinarySensorSchema from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -12,10 +21,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import ( @@ -60,7 +71,7 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: { CONF_NAME: "test_invert", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, }, ] } @@ -113,7 +124,7 @@ async def test_binary_sensor_ignore_internal_state( { CONF_NAME: "test_ignore", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_IGNORE_INTERNAL_STATE: True, CONF_SYNC_STATE: False, }, ] @@ -156,7 +167,7 @@ async def test_binary_sensor_counter( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_CONTEXT_TIMEOUT: context_timeout, + CONF_CONTEXT_TIMEOUT: context_timeout, CONF_SYNC_STATE: False, }, ] @@ -220,7 +231,7 @@ async def test_binary_sensor_reset( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_RESET_AFTER: 1, CONF_SYNC_STATE: False, }, ] @@ -279,7 +290,7 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, CONF_SYNC_STATE: False, }, ] @@ -295,3 +306,44 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: await knx.receive_write(_ADDRESS, True) state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF + + +@pytest.mark.parametrize( + ("knx_data"), + [ + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + }, + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + "invert": True, + }, + ], +) +async def test_binary_sensor_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], +) -> None: + """Test creating a binary sensor.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.BINARY_SENSOR, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", not knx_data.get("invert")) + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON + + +async def test_binary_sensor_ui_load(knx: KNXTestKit) -> None: + """Test loading a binary sensor from storage.""" + await knx.setup_integration(config_store_fixture="config_store_binarysensor.json") + await knx.assert_read("3/2/21", response=True, ignore_order=True) + knx.assert_state("binary_sensor.test", STATE_ON) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 8fb348f1724..b5a90428ef2 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -850,3 +850,91 @@ async def test_climate_humidity(hass: HomeAssistant, knx: KNXTestKit) -> None: HVACMode.HEAT, current_humidity=45.6, ) + + +async def test_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_mode="on", + swing_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_mode", + {"entity_id": "climate.test", "swing_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_mode="off") + + +async def test_horizontal_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate horizontal swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_horizontal_mode="on", + swing_horizontal_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_horizontal_mode", + {"entity_id": "climate.test", "swing_horizontal_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_horizontal_mode="off") diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 8ed79f837bb..3e4c9408542 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1278,7 +1278,7 @@ async def test_options_flow_connection_type( # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) - await knx.setup_integration({}) + await knx.setup_integration() menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) with patch( diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 116f4b5d839..aee0a4036ff 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -25,7 +25,7 @@ async def test_create_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_name = "Test no device" @@ -69,7 +69,7 @@ async def test_create_entity_error( hass_ws_client: WebSocketGenerator, ) -> None: """Test unsuccessful entity creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) # create entity with invalid platform @@ -116,7 +116,7 @@ async def test_update_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -163,7 +163,7 @@ async def test_update_entity_error( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -238,7 +238,7 @@ async def test_delete_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity deletion.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -270,7 +270,7 @@ async def test_delete_entity_error( hass_storage: dict[str, Any], ) -> None: """Test unsuccessful entity deletion.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) # delete unknown entity @@ -307,7 +307,7 @@ async def test_get_entity_config( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity config retrieval.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -355,7 +355,7 @@ async def test_get_entity_config_error( error_message_start: str, ) -> None: """Test entity config retrieval errors.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -376,7 +376,7 @@ async def test_validate_entity( hass_ws_client: WebSocketGenerator, ) -> None: """Test entity validation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index 04ff02f0611..356640dd8d0 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -22,7 +22,7 @@ async def test_create_device( hass_ws_client: WebSocketGenerator, ) -> None: """Test device creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -50,12 +50,11 @@ async def test_remove_device( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - load_config_store: None, hass_storage: dict[str, Any], ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) - await knx.setup_integration({}) + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") client = await hass_ws_client(hass) await knx.assert_read("1/0/21", response=True, ignore_order=True) # test light diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index e5f776a9404..e4a208906c6 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -28,7 +28,7 @@ async def test_if_fires_on_telegram( knx: KNXTestKit, ) -> None: """Test telegram device triggers firing.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -124,7 +124,7 @@ async def test_default_if_fires_on_telegram( # by default (without a user changing any) extra_fields are not added to the trigger and # pre 2024.2 device triggers did only support "destination" field so they didn't have # "group_value_write", "group_value_response", "group_value_read", "incoming", "outgoing" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -206,7 +206,7 @@ async def test_remove_device_trigger( ) -> None: """Test for removed callback when device trigger not used.""" automation_name = "telegram_trigger_automation" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -256,7 +256,7 @@ async def test_get_triggers( knx: KNXTestKit, ) -> None: """Test we get the expected device triggers from knx.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -279,7 +279,7 @@ async def test_get_trigger_capabilities( knx: KNXTestKit, ) -> None: """Test we get the expected capabilities telegram device trigger.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -361,7 +361,7 @@ async def test_invalid_device_trigger( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid telegram device trigger configuration.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -404,7 +404,7 @@ async def test_invalid_trigger_configuration( knx: KNXTestKit, ) -> None: """Test invalid telegram device trigger configuration at attach_trigger.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index bb60e66f7e7..6d4bf7e6007 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the KNX integration.""" +from typing import Any + import pytest from syrupy import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT @@ -40,7 +42,7 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -60,7 +62,7 @@ async def test_diagnostic_config_error( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -76,6 +78,7 @@ async def test_diagnostic_config_error( async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test diagnostics redacting data.""" @@ -95,8 +98,8 @@ async def test_diagnostic_redact( CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, ) - knx: KNXTestKit = KNXTestKit(hass, mock_config_entry) - await knx.setup_integration({}) + knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -117,7 +120,7 @@ async def test_diagnostics_project( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() knx.xknx.version = "0.0.0" # snapshot will contain project specific fields in `project_info` assert ( diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 48cc46ef1ee..579f9b143a2 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -1,7 +1,9 @@ """Test KNX init.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from xknx.io import ( DEFAULT_MCAST_GRP, @@ -11,7 +13,10 @@ from xknx.io import ( SecureConfig, ) -from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA +from homeassistant.components.knx.config_flow import ( + DEFAULT_ENTRY_DATA, + DEFAULT_ROUTING_IA, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, CONF_KNX_CONNECTION_TYPE, @@ -40,12 +45,13 @@ from homeassistant.components.knx.const import ( KNXConfigEntryData, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -220,7 +226,7 @@ async def test_init_connection_handling( data=config_entry_data, ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() assert hass.data.get(KNX_DOMAIN) is not None @@ -262,6 +268,79 @@ async def test_init_connection_handling( ) +async def _init_switch_and_wait_for_first_state_updater_run( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, + config_entry_data: KNXConfigEntryData, +) -> None: + """Return a config entry with default data.""" + config_entry = MockConfigEntry( + title="KNX", domain=KNX_DOMAIN, data=config_entry_data + ) + knx.mock_config_entry = config_entry + await knx.setup_integration() + await create_ui_entity( + platform=Platform.SWITCH, + knx_data={ + "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, + "respond_to_read": True, + "sync_state": True, # True uses xknx default state updater + "invert": False, + }, + ) + # created entity sends read-request to KNX bus on connection + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + freezer.tick(timedelta(minutes=59)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + await knx.assert_no_telegram() + + freezer.tick(timedelta(minutes=1)) # 60 minutes passed + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +async def test_default_state_updater_enabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=True, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + + +async def test_default_state_updater_disabled( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default state updater is applied to xknx device instances.""" + config_entry = DEFAULT_ENTRY_DATA | KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, # missing in default data + state_updater=False, + ) + await _init_switch_and_wait_for_first_state_updater_run( + hass, knx, create_ui_entity, freezer, config_entry + ) + await knx.assert_no_telegram() + + async def test_async_remove_entry( hass: HomeAssistant, knx: KNXTestKit, @@ -275,14 +354,14 @@ async def test_async_remove_entry( }, ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() with ( patch("pathlib.Path.unlink") as unlink_mock, patch("pathlib.Path.rmdir") as rmdir_mock, ): assert await hass.config_entries.async_remove(config_entry.entry_id) - assert unlink_mock.call_count == 3 + assert unlink_mock.call_count == 4 rmdir_mock.assert_called_once() assert hass.config_entries.async_entries() == [] diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 79114d4ffd5..4de366c69f0 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -25,7 +25,7 @@ async def test_diagnostic_entities( freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostic entities.""" - await knx.setup_integration({}) + await knx.setup_integration() for entity_id in ( "sensor.knx_interface_individual_address", @@ -103,7 +103,7 @@ async def test_removed_entity( with patch( "xknx.core.connection_manager.ConnectionManager.unregister_connection_state_changed_cb" ) as unregister_mock: - await knx.setup_integration({}) + await knx.setup_integration() entity_registry.async_update_entity( "sensor.knx_interface_connection_established", @@ -120,7 +120,7 @@ async def test_remove_interface_device( ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) knx_devices = device_registry.devices.get_devices_for_config_entry_id( knx.mock_config_entry.entry_id diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 6ba6090d60d..fb0246763a4 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1176,7 +1176,7 @@ async def test_light_ui_create( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a light.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1213,7 +1213,7 @@ async def test_light_ui_color_temp( raw_ct: tuple[int, ...], ) -> None: """Test creating a color-temp light.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1250,7 +1250,7 @@ async def test_light_ui_multi_mode( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a light with multiple color modes.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1335,13 +1335,11 @@ async def test_light_ui_multi_mode( async def test_light_ui_load( - hass: HomeAssistant, knx: KNXTestKit, - load_config_store: None, entity_registry: er.EntityRegistry, ) -> None: """Test loading a light from storage.""" - await knx.setup_integration({}) + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") await knx.assert_read("1/0/21", response=True, ignore_order=True) # unrelated switch in config store diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index f70389dbc92..c4b48b5e81d 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -111,7 +111,7 @@ async def test_send( expected_apci, ) -> None: """Test `knx.send` service.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.services.async_call( "knx", @@ -127,7 +127,7 @@ async def test_send( async def test_read(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test `knx.read` service.""" - await knx.setup_integration({}) + await knx.setup_integration() # send read telegram await hass.services.async_call("knx", "read", {"address": "1/1/1"}, blocking=True) @@ -150,7 +150,7 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: events = async_capture_events(hass, "knx_event") test_address = "1/2/3" - await knx.setup_integration({}) + await knx.setup_integration() # no event registered await knx.receive_write(test_address, True) @@ -200,7 +200,7 @@ async def test_exposure_register(hass: HomeAssistant, knx: KNXTestKit) -> None: test_entity = "fake.entity" test_attribute = "fake_attribute" - await knx.setup_integration({}) + await knx.setup_integration() # no exposure registered hass.states.async_set(test_entity, STATE_ON, {}) @@ -265,7 +265,7 @@ async def test_reload_service( knx: KNXTestKit, ) -> None: """Test reload service.""" - await knx.setup_integration({}) + await knx.setup_integration() with ( patch( @@ -285,7 +285,7 @@ async def test_reload_service( async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test service setup failed.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) with pytest.raises(HomeAssistantError) as exc_info: diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index bc0a6b27675..969c11b8e1a 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -155,7 +155,7 @@ async def test_switch_ui_create( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a switch.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.SWITCH, entity_data={"name": "test"}, @@ -171,3 +171,16 @@ async def test_switch_ui_create( await knx.receive_response("2/2/2", True) state = hass.states.get("switch.test") assert state.state is STATE_ON + + +async def test_switch_ui_load(knx: KNXTestKit) -> None: + """Test loading a switch from storage.""" + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") + + await knx.assert_read("1/0/45", response=True, ignore_order=True) + # unrelated light in config store + await knx.assert_read("1/0/21", response=True, ignore_order=True) + knx.assert_state( + "switch.none_test", # has_entity_name with unregistered device -> none_test + STATE_ON, + ) diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 883e8ccbb2d..840959bb6c5 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -70,7 +70,7 @@ async def test_store_telegam_history( hass_storage: dict[str, Any], ) -> None: """Test storing telegram history.""" - await knx.setup_integration({}) + await knx.setup_integration() await knx.receive_write("1/3/4", True) await hass.services.async_call( @@ -94,7 +94,7 @@ async def test_load_telegam_history( ) -> None: """Test telegram history restoration.""" hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} - await knx.setup_integration({}) + await knx.setup_integration() loaded_telegrams = hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams assert assert_telegram_history(loaded_telegrams) # TelegramDict "payload" is a tuple, this shall be restored when loading from JSON @@ -113,7 +113,7 @@ async def test_remove_telegam_history( knx.mock_config_entry, data=knx.mock_config_entry.data | {CONF_KNX_TELEGRAM_LOG_SIZE: 0}, ) - await knx.setup_integration({}, add_entry_to_hass=False) + await knx.setup_integration(add_entry_to_hass=False) # Store.async_remove() is mocked by hass_storage - check that data was removed. assert "knx/telegrams_history.json" not in hass_storage assert not hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams diff --git a/tests/components/knx/test_trigger.py b/tests/components/knx/test_trigger.py index 73e8b10840e..1ce42a23482 100644 --- a/tests/components/knx/test_trigger.py +++ b/tests/components/knx/test_trigger.py @@ -18,7 +18,7 @@ async def test_telegram_trigger( knx: KNXTestKit, ) -> None: """Test telegram triggers firing.""" - await knx.setup_integration({}) + await knx.setup_integration() # "id" field added to action to test if `trigger_data` passed correctly in `async_attach_trigger` assert await async_setup_component( @@ -105,7 +105,7 @@ async def test_telegram_trigger_dpt_option( expected_unit: str | None, ) -> None: """Test telegram trigger type option.""" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, automation.DOMAIN, @@ -190,7 +190,7 @@ async def test_telegram_trigger_options( direction_options: dict[str, bool], ) -> None: """Test telegram trigger options.""" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, automation.DOMAIN, @@ -266,7 +266,7 @@ async def test_remove_telegram_trigger( ) -> None: """Test for removed callback when telegram trigger not used.""" automation_name = "telegram_trigger_automation" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, @@ -311,7 +311,7 @@ async def test_invalid_trigger( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid telegram trigger configuration.""" - await knx.setup_integration({}) + await knx.setup_integration() caplog.clear() with caplog.at_level(logging.ERROR): assert await async_setup_component( diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index a34f126e4f4..7054d415ee9 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -20,7 +20,7 @@ async def test_knx_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/info command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/info"}) @@ -39,7 +39,7 @@ async def test_knx_info_command_with_project( load_knxproj: None, ) -> None: """Test knx/info command with loaded project.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/info"}) @@ -65,7 +65,7 @@ async def test_knx_project_file_process( _password = "pw-test" _parse_result = FIXTURE_PROJECT_DATA - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded @@ -100,7 +100,7 @@ async def test_knx_project_file_process_error( hass_ws_client: WebSocketGenerator, ) -> None: """Test knx/project_file_process exception handling.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded @@ -134,7 +134,7 @@ async def test_knx_project_file_remove( hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" - await knx.setup_integration({}) + await knx.setup_integration() assert hass_storage[KNX_PROJECT_STORAGE_KEY] client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded @@ -154,7 +154,7 @@ async def test_knx_get_project( load_knxproj: None, ) -> None: """Test retrieval of kxnproject from store.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded @@ -169,7 +169,7 @@ async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_monitor_info command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) @@ -184,7 +184,7 @@ async def test_knx_group_telegrams_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_telegrams command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "knx/group_telegrams"}) @@ -338,7 +338,7 @@ async def test_knx_subscribe_telegrams_command_project( load_knxproj: None, ) -> None: """Test knx/subscribe_telegrams command with project data.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) res = await client.receive_json() @@ -405,7 +405,7 @@ async def test_websocket_when_config_entry_unloaded( endpoint: str, ) -> None: """Test websocket connection when config entry is unloaded.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) client = await hass_ws_client(hass) diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index e56ba03b7e5..cc8acbaebf6 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = { "host": "1.1.1.1", @@ -17,7 +17,7 @@ TEST_CREDENTIALS = {"username": "username", "password": "password"} TEST_WS_PORT = {"ws_port": 9090} UUID = "11111111-1111-1111-1111-111111111111" -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, @@ -28,7 +28,7 @@ TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( ) -TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY_WO_UUID = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], port=8080, diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 5865616c544..c9fa70de256 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -5,10 +5,11 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import konnected, ssdp +from homeassistant.components import konnected from homeassistant.components.konnected import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from tests.common import MockConfigEntry @@ -116,7 +117,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -141,7 +142,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -160,7 +161,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -175,7 +176,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -193,7 +194,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -217,7 +218,7 @@ async def test_ssdp(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/Device.xml", @@ -343,7 +344,7 @@ async def test_import_ssdp_host_user_finish(hass: HomeAssistant, mock_panel) -> ssdp_result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -390,7 +391,7 @@ async def test_ssdp_already_configured(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://0.0.0.0:1234/Device.xml", @@ -470,7 +471,7 @@ async def test_ssdp_host_update(hass: HomeAssistant, mock_panel) -> None: result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.1.1.1:1234/Device.xml", diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 08f06684d9a..3a99a7f681d 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -57,6 +57,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "client": { "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index a2245e721c5..230a2562282 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 913f6c72f24..7221fa4c071 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -15,7 +15,13 @@ TEST_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -26,7 +32,13 @@ TEST_NO_PERMISSION_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": False}, model="Test", ) @@ -37,7 +49,16 @@ TEST_UNSUPPORTED_SENSOR = Sensor( sensor_id="2", sensor_field_names=["SomeUnsupportedField"], location=Location(id="1", name="Test"), - data={"SomeUnsupportedField": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "SomeUnsupportedField": { + "spot": {"value": "2"}, + "unit": "degrees_celsius", + } + } + } + }, permissions={"read": True}, model="Test", ) @@ -48,7 +69,13 @@ TEST_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.3"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.3"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -59,7 +86,9 @@ TEST_STRING_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WetDry"], location=Location(id="1", name="Test"), - data={"WetDry": {"values": [{"s": "dry"}], "unit": "wet_dry"}}, + data={ + "data": {"current": {"WetDry": {"spot": {"value": "dry"}, "unit": "wet_dry"}}} + }, permissions={"read": True}, model="Test", ) @@ -70,7 +99,13 @@ TEST_ALREADY_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "HeatIndex": {"spot": {"value": 2.3}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -81,7 +116,13 @@ TEST_ALREADY_INT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, + data={ + "data": { + "current": { + "WindSpeed": {"spot": {"value": 2}, "unit": "kilometers_per_hour"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -92,7 +133,7 @@ TEST_NO_FIELD_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={}, + data={"data": {"current": {}}}, permissions={"read": True}, model="Test", ) @@ -103,7 +144,7 @@ TEST_MISSING_FIELD_DATA_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": None}, + data={"data": {"current": {"Temperature": None}}}, permissions={"read": True}, model="Test", ) @@ -114,7 +155,35 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.1"}, "unit": "degrees_fahrenheit"} + } + } + }, + permissions={"read": True}, + model="Test", +) +TEST_NO_READINGS_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "no_readings"}, + permissions={"read": True}, + model="Test", +) +TEST_OTHER_ERROR_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "some_other_error"}, permissions={"read": True}, model="Test", ) diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 201bbbc971e..0975704b680 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -4,7 +4,7 @@ 'coordinator_data': list([ dict({ '__type': "", - 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'values': [{'s': '2'}], 'unit': 'degrees_celsius'}})", + 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'spot': {'value': '2'}, 'unit': 'degrees_celsius'}})", }), ]), 'entry': dict({ @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py index dc48f160113..4306173c6b3 100644 --- a/tests/components/lacrosse_view/test_diagnostics.py +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -26,9 +26,14 @@ async def test_entry_diagnostics( ) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 51fa7e5abf4..0533dd2abee 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -20,12 +20,17 @@ async def test_unload_entry(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -68,7 +73,7 @@ async def test_http_error(hass: HomeAssistant) -> None: with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", side_effect=HTTPError), + patch("lacrosse_view.LaCrosse.get_devices", side_effect=HTTPError), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -78,18 +83,40 @@ async def test_http_error(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY + config_entry_2 = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry_2.add_to_hass(hass) + + # Start over, let get_devices succeed but get_sensor_status fail + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_sensor_status", side_effect=HTTPError), + ): + assert not await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 2 + assert entries[1].state is ConfigEntryState.SETUP_RETRY + async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test new token.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -103,7 +130,7 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR], ), ): @@ -121,12 +148,17 @@ async def test_failed_token( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 11faaf8877e..e0dc1e5f35f 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -18,6 +18,8 @@ from . import ( TEST_MISSING_FIELD_DATA_SENSOR, TEST_NO_FIELD_SENSOR, TEST_NO_PERMISSION_SENSOR, + TEST_NO_READINGS_SENSOR, + TEST_OTHER_ERROR_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, TEST_UNITS_OVERRIDE_SENSOR, @@ -32,9 +34,14 @@ async def test_entities_added(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,12 +61,17 @@ async def test_sensor_permission( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_PERMISSION_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_PERMISSION_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -79,11 +91,14 @@ async def test_field_not_supported( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_UNSUPPORTED_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch( - "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_UNSUPPORTED_SENSOR] - ), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -104,7 +119,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), - (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6111111111111", "temperature"), ], ) async def test_field_types( @@ -114,12 +129,17 @@ async def test_field_types( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = test_input.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[test_input], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -137,12 +157,17 @@ async def test_no_field(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_FIELD_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_FIELD_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -160,12 +185,17 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_MISSING_FIELD_DATA_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_MISSING_FIELD_DATA_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -176,3 +206,57 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED assert hass.states.get("sensor.test_temperature").state == "unknown" + + +async def test_no_readings(hass: HomeAssistant) -> None: + """Test behavior when there are no readings.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_NO_READINGS_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert hass.states.get("sensor.test_temperature").state == "unavailable" + + +async def test_other_error(hass: HomeAssistant) -> None: + """Test behavior when there is an error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_OTHER_ERROR_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 47bca8dcb63..6cd4e8cd5ae 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr index 64d47a11072..33aace5f97a 100644 --- a/tests/components/lamarzocco/snapshots/test_button.ambr +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_calendar.ambr b/tests/components/lamarzocco/snapshots/test_calendar.ambr index 729eed5879a..74847892cfa 100644 --- a/tests/components/lamarzocco/snapshots/test_calendar.ambr +++ b/tests/components/lamarzocco/snapshots/test_calendar.ambr @@ -90,6 +90,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +124,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index 67aa0b8bea8..4c210136bd2 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -43,6 +44,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 49e4713aab1..0748c9384a9 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -30,6 +30,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -87,6 +88,7 @@ 'step': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +146,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -201,6 +204,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +262,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -315,6 +320,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +678,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +736,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -786,6 +794,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -843,6 +852,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -900,6 +910,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +968,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1012,6 +1024,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1067,6 +1080,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 325409a0b7f..2e88688652a 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -28,6 +28,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -142,6 +144,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +202,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -254,6 +258,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +316,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 723f9738e1c..996dff93433 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,210 @@ 'unit_of_measurement': '%', }) # --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Coffees made Key 1', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key1', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 1', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1047', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Coffees made Key 2', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key2', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 2', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '560', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Coffees made Key 3', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key3', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 3', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '468', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Coffees made Key 4', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key4', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 4', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '312', + }) +# --- # name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -59,6 +264,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -167,6 +374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +426,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,7 +450,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_coffee', 'unique_id': 'GS012345_drink_stats_coffee', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }) # --- # name: test_sensors[sensor.gs012345_total_coffees_made-state] @@ -249,14 +458,14 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total coffees made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }), 'context': , 'entity_id': 'sensor.gs012345_total_coffees_made', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1047', + 'state': '2387', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-entry] @@ -268,6 +477,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -291,7 +501,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_flushing', 'unique_id': 'GS012345_drink_stats_flushing', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-state] @@ -299,7 +509,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total flushes made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }), 'context': , 'entity_id': 'sensor.gs012345_total_flushes_made', diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 72fe41c1392..085d9a16125 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -39,6 +40,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 40f47a783d7..17d0528c3d8 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index e25aab39012..02ade8f2b9c 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -8,7 +8,6 @@ from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN from homeassistant.config_entries import ( @@ -28,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 3385e2b3891..43a0826d551 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -18,6 +18,7 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, mock_lamarzocco: MagicMock, diff --git a/tests/components/lametric/fixtures/computer_powered.json b/tests/components/lametric/fixtures/computer_powered.json new file mode 100644 index 00000000000..0465dd4dd3a --- /dev/null +++ b/tests/components/lametric/fixtures/computer_powered.json @@ -0,0 +1,68 @@ +{ + "audio": { + "available": true, + "volume": 53, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": false, + "address": "40:F4:C9:AA:AA:AA", + "available": true, + "discoverable": true, + "mac": "40:F4:C9:AA:AA:AA", + "name": "LM8367", + "pairable": false + }, + "display": { + "brightness": 75, + "brightness_limit": { + "max": 76, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "time_based": { + "enabled": false + }, + "when_dark": { + "enabled": true + } + }, + "widget": "1_com.lametric.clock" + }, + "type": "mixed", + "width": 37 + }, + "id": "67790", + "mode": "manual", + "model": "sa8", + "name": "TIME", + "os_version": "3.1.3", + "serial_number": "SA840700836700W00BAA", + "wifi": { + "active": true, + "mac": "40:F4:C9:AA:AA:AA", + "available": true, + "encryption": "WPA", + "ssid": "My wifi", + "ip": "10.0.0.99", + "mode": "dhcp", + "netmask": "255.255.255.0", + "rssi": 78 + } +} diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index 8b8f98b5806..d8f21424216 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -24,7 +24,15 @@ 'device_id': '**REDACTED**', 'display': dict({ 'brightness': 100, + 'brightness_limit': dict({ + 'range_max': 100, + 'range_min': 2, + }), 'brightness_mode': 'auto', + 'brightness_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), 'display_type': 'mixed', 'height': 8, 'on': None, diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index 04efeaac87f..cc8c1379fe0 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -52,6 +52,7 @@ async def test_button_app_next( assert device_entry.model_id == "LM 37X8" assert device_entry.name == "Frenck's LaMetric" assert device_entry.sw_version == "2.2.2" + assert device_entry.serial_number == "SA110405124500W00BS9" assert device_entry.hw_version is None await hass.services.async_call( diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index ccbbe005639..c0fb98f1908 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -13,18 +13,18 @@ from demetriek import ( ) import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.lametric.const import DOMAIN -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_SERIAL, - SsdpServiceInfo, -) from homeassistant.config_entries import SOURCE_DHCP, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_DEVICE, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 681abf850d2..6e052603c24 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -42,7 +42,7 @@ async def test_brightness( assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_MAX) == 100 - assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MIN) == 2 assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "100" @@ -62,6 +62,7 @@ async def test_brightness( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( @@ -183,3 +184,16 @@ async def test_number_connection_error( state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["computer_powered"]) +async def test_computer_powered_devices( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test Brightness is properly limited for computer powered devices.""" + state = hass.states.get("number.time_brightness") + assert state + assert state.state == "75" + assert state.attributes[ATTR_MIN] == 2 + assert state.attributes[ATTR_MAX] == 76 diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 6b3fa291e9c..e4b9870f52b 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -55,6 +55,7 @@ async def test_brightness_mode( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 8dff11fb450..08b289e2425 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -48,4 +48,5 @@ async def test_wifi_signal( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 367d5605e06..3e73b710942 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -57,6 +57,7 @@ async def test_bluetooth( assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} assert device.manufacturer == "LaMetric Inc." assert device.name == "Frenck's LaMetric" + assert device.serial_number == "SA110405124500W00BS9" assert device.sw_version == "2.2.2" await hass.services.async_call( diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py index 0735d4541ff..be588b86e80 100644 --- a/tests/components/lawn_mower/test_init.py +++ b/tests/components/lawn_mower/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.lawn_mower import ( from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -97,7 +97,7 @@ async def test_lawn_mower_setup(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test platform via config entry.""" async_add_entities([entity1]) diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json b/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json index e1893c30b42..7dea4405fc5 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json +++ b/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json @@ -13,13 +13,6 @@ "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 - }, - { - "address": [0, 5, true], - "name": "TestGroup", - "hardware_serial": -1, - "software_serial": -1, - "hardware_type": -1 } ], "entities": [ @@ -33,216 +26,6 @@ "dimmable": true, "transition": 5000.0 } - }, - { - "address": [0, 7, false], - "name": "Light_Output2", - "resource": "output2", - "domain": "light", - "domain_data": { - "output": "OUTPUT2", - "dimmable": false, - "transition": 0 - } - }, - { - "address": [0, 7, false], - "name": "Light_Relay1", - "resource": "relay1", - "domain": "light", - "domain_data": { - "output": "RELAY1", - "dimmable": false, - "transition": 0.0 - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output1", - "resource": "output1", - "domain": "switch", - "domain_data": { - "output": "OUTPUT1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output2", - "resource": "output2", - "domain": "switch", - "domain_data": { - "output": "OUTPUT2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay1", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay2", - "resource": "relay2", - "domain": "switch", - "domain_data": { - "output": "RELAY2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Regulator1", - "resource": "r1varsetpoint", - "domain": "switch", - "domain_data": { - "output": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Switch_KeyLock1", - "resource": "a1", - "domain": "switch", - "domain_data": { - "output": "A1" - } - }, - { - "address": [0, 5, true], - "name": "Switch_Group5", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Outputs", - "resource": "outputs", - "domain": "cover", - "domain_data": { - "motor": "OUTPUTS", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Relays", - "resource": "motor1", - "domain": "cover", - "domain_data": { - "motor": "MOTOR1", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Climate1", - "resource": "var1.r1varsetpoint", - "domain": "climate", - "domain_data": { - "source": "VAR1", - "setpoint": "R1VARSETPOINT", - "lockable": true, - "min_temp": 0.0, - "max_temp": 40.0, - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Romantic", - "resource": "0.0", - "domain": "scene", - "domain_data": { - "register": 0, - "scene": 0, - "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], - "transition": null - } - }, - { - "address": [0, 7, false], - "name": "Romantic Transition", - "resource": "0.1", - "domain": "scene", - "domain_data": { - "register": 0, - "scene": 1, - "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], - "transition": 10000 - } - }, - { - "address": [0, 7, false], - "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", - "domain": "binary_sensor", - "domain_data": { - "source": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Binary_Sensor1", - "resource": "binsensor1", - "domain": "binary_sensor", - "domain_data": { - "source": "BINSENSOR1" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_KeyLock", - "resource": "a5", - "domain": "binary_sensor", - "domain_data": { - "source": "A5" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Var1", - "resource": "var1", - "domain": "sensor", - "domain_data": { - "source": "VAR1", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", - "domain": "sensor", - "domain_data": { - "source": "R1VARSETPOINT", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Led6", - "resource": "led6", - "domain": "sensor", - "domain_data": { - "source": "LED6", - "unit_of_measurement": "NATIVE" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_LogicOp1", - "resource": "logicop1", - "domain": "sensor", - "domain_data": { - "source": "LOGICOP1", - "unit_of_measurement": "NATIVE" - } } ] } diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json b/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json index 7389079dca9..4cade6b64d0 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json +++ b/tests/components/lcn/fixtures/config_entry_pchk_v1_2.json @@ -14,13 +14,6 @@ "hardware_serial": -1, "software_serial": -1, "hardware_type": -1 - }, - { - "address": [0, 5, true], - "name": "TestGroup", - "hardware_serial": -1, - "software_serial": -1, - "hardware_type": -1 } ], "entities": [ @@ -43,115 +36,7 @@ "domain_data": { "output": "OUTPUT2", "dimmable": false, - "transition": 0 - } - }, - { - "address": [0, 7, false], - "name": "Light_Relay1", - "resource": "relay1", - "domain": "light", - "domain_data": { - "output": "RELAY1", - "dimmable": false, - "transition": 0.0 - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output1", - "resource": "output1", - "domain": "switch", - "domain_data": { - "output": "OUTPUT1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Output2", - "resource": "output2", - "domain": "switch", - "domain_data": { - "output": "OUTPUT2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay1", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Relay2", - "resource": "relay2", - "domain": "switch", - "domain_data": { - "output": "RELAY2" - } - }, - { - "address": [0, 7, false], - "name": "Switch_Regulator1", - "resource": "r1varsetpoint", - "domain": "switch", - "domain_data": { - "output": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Switch_KeyLock1", - "resource": "a1", - "domain": "switch", - "domain_data": { - "output": "A1" - } - }, - { - "address": [0, 5, true], - "name": "Switch_Group5", - "resource": "relay1", - "domain": "switch", - "domain_data": { - "output": "RELAY1" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Outputs", - "resource": "outputs", - "domain": "cover", - "domain_data": { - "motor": "OUTPUTS", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Cover_Relays", - "resource": "motor1", - "domain": "cover", - "domain_data": { - "motor": "MOTOR1", - "reverse_time": "RT1200" - } - }, - { - "address": [0, 7, false], - "name": "Climate1", - "resource": "var1.r1varsetpoint", - "domain": "climate", - "domain_data": { - "source": "VAR1", - "setpoint": "R1VARSETPOINT", - "lockable": true, - "min_temp": 0.0, - "max_temp": 40.0, - "unit_of_measurement": "°C" + "transition": null } }, { @@ -177,73 +62,6 @@ "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], "transition": 10000 } - }, - { - "address": [0, 7, false], - "name": "Sensor_LockRegulator1", - "resource": "r1varsetpoint", - "domain": "binary_sensor", - "domain_data": { - "source": "R1VARSETPOINT" - } - }, - { - "address": [0, 7, false], - "name": "Binary_Sensor1", - "resource": "binsensor1", - "domain": "binary_sensor", - "domain_data": { - "source": "BINSENSOR1" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_KeyLock", - "resource": "a5", - "domain": "binary_sensor", - "domain_data": { - "source": "A5" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Var1", - "resource": "var1", - "domain": "sensor", - "domain_data": { - "source": "VAR1", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Setpoint1", - "resource": "r1varsetpoint", - "domain": "sensor", - "domain_data": { - "source": "R1VARSETPOINT", - "unit_of_measurement": "°C" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_Led6", - "resource": "led6", - "domain": "sensor", - "domain_data": { - "source": "LED6", - "unit_of_measurement": "NATIVE" - } - }, - { - "address": [0, 7, false], - "name": "Sensor_LogicOp1", - "resource": "logicop1", - "domain": "sensor", - "domain_data": { - "source": "LOGICOP1", - "unit_of_measurement": "NATIVE" - } } ] } diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index 0ad31437dd1..d2d697569d1 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr index 443b13312d1..81745ca8515 100644 --- a/tests/components/lcn/snapshots/test_climate.ambr +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'min_temp': 0.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index 82a19060d73..d399626537d 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_init.ambr b/tests/components/lcn/snapshots/test_init.ambr new file mode 100644 index 00000000000..ea6267aaa0b --- /dev/null +++ b/tests/components/lcn/snapshots/test_init.ambr @@ -0,0 +1,140 @@ +# serializer version: 1 +# name: test_migrate_1_1 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + 'resource': 'output1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- +# name: test_migrate_1_2 + dict({ + 'acknowledge': False, + 'devices': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'hardware_serial': -1, + 'hardware_type': -1, + 'name': 'TestModule', + 'software_serial': -1, + }), + ]), + 'dim_mode': 'STEPS200', + 'entities': list([ + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': True, + 'output': 'OUTPUT1', + 'transition': 5.0, + }), + 'name': 'Light_Output1', + 'resource': 'output1', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'light', + 'domain_data': dict({ + 'dimmable': False, + 'output': 'OUTPUT2', + 'transition': 0.0, + }), + 'name': 'Light_Output2', + 'resource': 'output2', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 0, + 'transition': 0.0, + }), + 'name': 'Romantic', + 'resource': '0.0', + }), + dict({ + 'address': tuple( + 0, + 7, + False, + ), + 'domain': 'scene', + 'domain_data': dict({ + 'outputs': list([ + 'OUTPUT1', + 'OUTPUT2', + 'RELAY1', + ]), + 'register': 0, + 'scene': 1, + 'transition': 10.0, + }), + 'name': 'Romantic Transition', + 'resource': '0.1', + }), + ]), + 'host': 'pchk', + 'ip_address': '192.168.2.41', + 'password': 'lcn', + 'port': 4114, + 'sk_num_tries': 0, + 'username': 'lcn', + }) +# --- diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr index f53d1fdf2dc..638cddc15cd 100644 --- a/tests/components/lcn/snapshots/test_light.ambr +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +123,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr index c039c4ef951..a5576158621 100644 --- a/tests/components/lcn/snapshots/test_scene.ambr +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr index 56776e3e0f6..f8d57ed8904 100644 --- a/tests/components/lcn/snapshots/test_sensor.ambr +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr index 36145b8d4fd..bc69b0ed483 100644 --- a/tests/components/lcn/snapshots/test_switch.ambr +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 2f64f421b93..7d636f546c4 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import MockConfigEntry, init_integration diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 7ba263bd597..7bac7cc9e81 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -107,7 +107,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None @@ -124,7 +124,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> blocking=True, ) - lock_regulator.assert_awaited_with(0, True) + lock_regulator.assert_awaited_with(0, True, -1) state = hass.states.get("climate.climate1") assert state is not None diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 6537c108981..94eb96591e2 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -45,9 +45,14 @@ async def test_get_triggers_module_device( ) ] - triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, device.id - ) + triggers = [ + trigger + for trigger in await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + if trigger[CONF_DOMAIN] == DOMAIN + ] + assert triggers == unordered(expected_triggers) @@ -63,11 +68,8 @@ async def test_get_triggers_non_module_device( identifiers={(DOMAIN, entry.entry_id)} ) group_device = get_device(hass, entry, (0, 5, True)) - resource_device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{entry.entry_id}-m000007-output1")} - ) - for device in (host_device, group_device, resource_device): + for device in (host_device, group_device): triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 4bb8d023d3f..ef3c2d3cb66 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -11,6 +11,7 @@ from pypck.connection import ( ) from pypck.lcn_defs import LcnEvent import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.lcn.const import DOMAIN @@ -134,7 +135,7 @@ async def test_async_entry_reload_on_host_event_received( @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: +async def test_migrate_1_1(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_1 = create_config_entry("pchk_v1_1", version=(1, 1)) entry_v1_1.add_to_hass(hass) @@ -143,14 +144,15 @@ async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: await hass.async_block_till_done() entry_migrated = hass.config_entries.async_get_entry(entry_v1_1.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED assert entry_migrated.version == 2 assert entry_migrated.minor_version == 1 - assert entry_migrated.data == entry.data + assert entry_migrated.data == snapshot @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_migrate_1_2(hass: HomeAssistant, entry) -> None: +async def test_migrate_1_2(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test migration config entry.""" entry_v1_2 = create_config_entry("pchk_v1_2", version=(1, 2)) entry_v1_2.add_to_hass(hass) @@ -159,7 +161,8 @@ async def test_migrate_1_2(hass: HomeAssistant, entry) -> None: await hass.async_block_till_done() entry_migrated = hass.config_entries.async_get_entry(entry_v1_2.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED assert entry_migrated.version == 2 assert entry_migrated.minor_version == 1 - assert entry_migrated.data == entry.data + assert entry_migrated.data == snapshot diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index cd97e3484e3..c9eda40fdba 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( diff --git a/tests/components/led_ble/test_config_flow.py b/tests/components/led_ble/test_config_flow.py index c22c62e2fb1..674700aebd9 100644 --- a/tests/components/led_ble/test_config_flow.py +++ b/tests/components/led_ble/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from bleak import BleakError +from led_ble import CharacteristicMissingError from homeassistant import config_entries from homeassistant.components.led_ble.const import DOMAIN @@ -202,6 +203,35 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_not_supported(hass: HomeAssistant) -> None: + """Test user step with a non supported device.""" + with patch( + "homeassistant.components.led_ble.config_flow.async_discovered_service_info", + return_value=[LED_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + side_effect=CharacteristicMissingError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: LED_BLE_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "not_supported" + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/lektrico/conftest.py b/tests/components/lektrico/conftest.py index fd840b0c290..0b120cd6e23 100644 --- a/tests/components/lektrico/conftest.py +++ b/tests/components/lektrico/conftest.py @@ -8,13 +8,13 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.lektrico.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import ( ATTR_HW_VERSION, ATTR_SERIAL_NUMBER, CONF_HOST, CONF_TYPE, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr index 6a28e7c60de..b365ff84187 100644 --- a/tests/components/lektrico/snapshots/test_binary_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_button.ambr b/tests/components/lektrico/snapshots/test_button.ambr index 5070cd484c4..f9cb7189237 100644 --- a/tests/components/lektrico/snapshots/test_button.ambr +++ b/tests/components/lektrico/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index 63739e1c9d8..35183bf5d75 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr index 30a37a25a09..57cf40567e7 100644 --- a/tests/components/lektrico/snapshots/test_number.ambr +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_select.ambr b/tests/components/lektrico/snapshots/test_select.ambr index 5a964f52ada..0f564abb146 100644 --- a/tests/components/lektrico/snapshots/test_select.ambr +++ b/tests/components/lektrico/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_sensor.ambr b/tests/components/lektrico/snapshots/test_sensor.ambr index 73ec88e6fa1..aa146f55776 100644 --- a/tests/components/lektrico/snapshots/test_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,6 +207,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +271,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +334,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,6 +399,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -452,6 +460,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +510,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_switch.ambr b/tests/components/lektrico/snapshots/test_switch.ambr index 3f4a1693315..c55e96ac9a9 100644 --- a/tests/components/lektrico/snapshots/test_switch.ambr +++ b/tests/components/lektrico/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py new file mode 100644 index 00000000000..6e73bb430cf --- /dev/null +++ b/tests/components/letpot/__init__.py @@ -0,0 +1,67 @@ +"""Tests for the LetPot integration.""" + +import datetime + +from letpot.models import ( + AuthenticationInfo, + LetPotDeviceErrors, + LetPotDeviceStatus, + TemperatureUnit, +) + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +AUTHENTICATION = AuthenticationInfo( + access_token="access_token", + access_token_expires=1738368000, # 2025-02-01 00:00:00 GMT + refresh_token="refresh_token", + refresh_token_expires=1740441600, # 2025-02-25 00:00:00 GMT + user_id="a1b2c3d4e5f6a1b2c3d4e5f6", + email="email@example.com", +) + +MAX_STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[], # Not used by integration, and it requires a real device to get + system_on=True, + system_sound=False, + temperature_unit=TemperatureUnit.CELSIUS, + temperature_value=18, + water_mode=1, + water_level=100, +) + +SE_STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[], # Not used by integration, and it requires a real device to get + system_on=True, + system_sound=False, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py new file mode 100644 index 00000000000..25974b2d78a --- /dev/null +++ b/tests/components/letpot/conftest.py @@ -0,0 +1,138 @@ +"""Common fixtures for the LetPot tests.""" + +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, patch + +from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL + +from . import AUTHENTICATION, MAX_STATUS, SE_STATUS + +from tests.common import MockConfigEntry + + +@pytest.fixture +def device_type() -> str: + """Return the device type to use for mock data.""" + return "LPH63" + + +def _mock_device_features(device_type: str) -> DeviceFeature: + """Return mock device feature support for the given type.""" + if device_type == "LPH31": + return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + if device_type == "LPH63": + return ( + DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.PUMP_STATUS + | DeviceFeature.TEMPERATURE + | DeviceFeature.WATER_LEVEL + ) + raise ValueError(f"No mock data for device type {device_type}") + + +def _mock_device_status(device_type: str) -> LetPotDeviceStatus: + """Return mock device status for the given type.""" + if device_type == "LPH31": + return SE_STATUS + if device_type == "LPH63": + return MAX_STATUS + raise ValueError(f"No mock data for device type {device_type}") + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.letpot.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_client(device_type: str) -> Generator[AsyncMock]: + """Mock a LetPotClient.""" + with ( + patch( + "homeassistant.components.letpot.LetPotClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.letpot.config_flow.LetPotClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = AUTHENTICATION + client.refresh_token.return_value = AUTHENTICATION + client.get_devices.return_value = [ + LetPotDevice( + serial_number=f"{device_type}ABCD", + name="Garden", + device_type=device_type, + is_online=True, + is_remote=False, + ) + ] + yield client + + +@pytest.fixture +def mock_device_client(device_type: str) -> Generator[AsyncMock]: + """Mock a LetPotDeviceClient.""" + with patch( + "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + autospec=True, + ) as mock_device_client: + device_client = mock_device_client.return_value + device_client.device_features = _mock_device_features(device_type) + device_client.device_model_code = device_type + device_client.device_model_name = f"LetPot {device_type}" + device_status = _mock_device_status(device_type) + + subscribe_callbacks: list[Callable] = [] + + def subscribe_side_effect(callback: Callable) -> None: + subscribe_callbacks.append(callback) + + def status_side_effect() -> None: + # Deliver a status update to any subscribers, like the real client + for callback in subscribe_callbacks: + callback(device_status) + + device_client.get_current_status.side_effect = status_side_effect + device_client.get_current_status.return_value = device_status + device_client.last_status.return_value = device_status + device_client.request_status_update.side_effect = status_side_effect + device_client.subscribe.side_effect = subscribe_side_effect + + yield device_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=AUTHENTICATION.email, + data={ + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + }, + unique_id=AUTHENTICATION.user_id, + ) diff --git a/tests/components/letpot/snapshots/test_binary_sensor.ambr b/tests/components/letpot/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..121cf4e3f82 --- /dev/null +++ b/tests/components/letpot/snapshots/test_binary_sensor.ambr @@ -0,0 +1,337 @@ +# serializer version: 1 +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Pump error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low nutrients', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_nutrients', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_nutrients', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low nutrients', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refill error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'refill_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_refill_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Refill error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/letpot/snapshots/test_sensor.ambr b/tests/components/letpot/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5d123cf6ce0 --- /dev/null +++ b/tests/components/letpot/snapshots/test_sensor.ambr @@ -0,0 +1,104 @@ +# serializer version: 1 +# name: test_all_entities[sensor.garden_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garden Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_water_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Water level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/letpot/snapshots/test_switch.ambr b/tests/components/letpot/snapshots/test_switch.ambr new file mode 100644 index 00000000000..1a36e555dd1 --- /dev/null +++ b/tests/components/letpot/snapshots/test_switch.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_all_entities[switch.garden_alarm_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_alarm_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm sound', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_sound', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_alarm_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Alarm sound', + }), + 'context': , + 'entity_id': 'switch.garden_alarm_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.garden_auto_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_auto_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto mode', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_auto_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_auto_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Auto mode', + }), + 'context': , + 'entity_id': 'switch.garden_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.garden_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Power', + }), + 'context': , + 'entity_id': 'switch.garden_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.garden_pump_cycling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_pump_cycling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump cycling', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_cycling', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_pump_cycling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Pump cycling', + }), + 'context': , + 'entity_id': 'switch.garden_pump_cycling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/letpot/snapshots/test_time.ambr b/tests/components/letpot/snapshots/test_time.ambr new file mode 100644 index 00000000000..9ca75003e56 --- /dev/null +++ b/tests/components/letpot/snapshots/test_time.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_all_entities[time.garden_light_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.garden_light_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light off', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_schedule_end', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.garden_light_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light off', + }), + 'context': , + 'entity_id': 'time.garden_light_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18:00:00', + }) +# --- +# name: test_all_entities[time.garden_light_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.garden_light_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light on', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_schedule_start', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.garden_light_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light on', + }), + 'context': , + 'entity_id': 'time.garden_light_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- diff --git a/tests/components/letpot/test_binary_sensor.py b/tests/components/letpot/test_binary_sensor.py new file mode 100644 index 00000000000..03ce1bee1a5 --- /dev/null +++ b/tests/components/letpot/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test binary sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("device_type", ["LPH63", "LPH31"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_type: str, +) -> None: + """Test binary sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py new file mode 100644 index 00000000000..a659b235213 --- /dev/null +++ b/tests/components/letpot/test_config_flow.py @@ -0,0 +1,299 @@ +"""Test the LetPot config flow.""" + +import dataclasses +from typing import Any +from unittest.mock import AsyncMock + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.components.letpot.const import ( + CONF_ACCESS_TOKEN_EXPIRES, + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_EXPIRES, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import AUTHENTICATION + +from tests.common import MockConfigEntry + + +def _assert_result_success(result: Any) -> None: + """Assert successful end of flow result, creating an entry.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == AUTHENTICATION.email + assert result["data"] == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert result["result"].unique_id == AUTHENTICATION.user_id + + +async def test_full_flow( + hass: HomeAssistant, mock_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test full flow with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_exceptions( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow with exception during login and recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + _assert_result_success(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_duplicate( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test flow aborts when trying to add a previously added account.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with success.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (LetPotAuthenticationException, "invalid_auth"), + (LetPotConnectionException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow with exception during login and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Retry to show recovery. + updated_auth = dataclasses.replace( + AUTHENTICATION, + access_token="new_access_token", + refresh_token="new_refresh_token", + ) + mock_client.login.return_value = updated_auth + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: "new_access_token", + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: "new_refresh_token", + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: AUTHENTICATION.user_id, + CONF_EMAIL: AUTHENTICATION.email, + } + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_reauth_different_user_id_new( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with different, new user ID updating the existing entry.""" + mock_config_entry.add_to_hass(hass) + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == AUTHENTICATION.user_id + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_ACCESS_TOKEN: AUTHENTICATION.access_token, + CONF_ACCESS_TOKEN_EXPIRES: AUTHENTICATION.access_token_expires, + CONF_REFRESH_TOKEN: AUTHENTICATION.refresh_token, + CONF_REFRESH_TOKEN_EXPIRES: AUTHENTICATION.refresh_token_expires, + CONF_USER_ID: "new_user_id", + CONF_EMAIL: AUTHENTICATION.email, + } + config_entries = hass.config_entries.async_entries() + assert len(config_entries) == 1 + assert config_entries[0].unique_id == "new_user_id" + + +async def test_reauth_different_user_id_existing( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with different, existing user ID aborting.""" + mock_config_entry.add_to_hass(hass) + mock_other = MockConfigEntry( + domain=DOMAIN, title="email2@example.com", data={}, unique_id="other_user_id" + ) + mock_other.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries()) == 2 diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py new file mode 100644 index 00000000000..e3f78d87dc1 --- /dev/null +++ b/tests/components/letpot/test_init.py @@ -0,0 +1,131 @@ +"""Test the LetPot integration initialization and setup.""" + +from unittest.mock import MagicMock + +from letpot.exceptions import ( + LetPotAuthenticationException, + LetPotConnectionException, + LetPotException, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2025-01-31 00:00:00") +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_not_called() # Didn't refresh valid token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_device_client.disconnect.assert_called_once() + + +@pytest.mark.freeze_time("2025-02-15 00:00:00") +async def test_refresh_authentication_on_load( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test expired access token refreshed when needed to load config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_called_once() + + # Check loading continued as expected after refreshing token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + +@pytest.mark.freeze_time("2025-03-01 00:00:00") +async def test_refresh_token_error_aborts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test expired refresh token aborting config entry loading.""" + mock_client.refresh_token.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_client.refresh_token.assert_called_once() + mock_client.get_devices.assert_not_called() + + +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (LetPotAuthenticationException, ConfigEntryState.SETUP_ERROR), + (LetPotConnectionException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_get_devices_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test config entry errors if an exception is raised when getting devices.""" + mock_client.get_devices.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is config_entry_state + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_not_called() + + +async def test_device_subscribe_authentication_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors if it is not allowed to subscribe to device updates.""" + mock_device_client.subscribe.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_not_called() + + +async def test_device_refresh_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors with retry if getting a device state update fails.""" + mock_device_client.get_current_status.side_effect = LetPotException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_device_client.get_current_status.assert_called_once() diff --git a/tests/components/letpot/test_sensor.py b/tests/components/letpot/test_sensor.py new file mode 100644 index 00000000000..a527d062ca7 --- /dev/null +++ b/tests/components/letpot/test_sensor.py @@ -0,0 +1,28 @@ +"""Test sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py new file mode 100644 index 00000000000..0ba1f556bc9 --- /dev/null +++ b/tests/components/letpot/test_switch.py @@ -0,0 +1,113 @@ +"""Test switch entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "parameter_value"), + [ + ( + SERVICE_TURN_ON, + True, + ), + ( + SERVICE_TURN_OFF, + False, + ), + ( + SERVICE_TOGGLE, + False, # Mock switch is on after setup, toggle will turn off + ), + ], +) +async def test_set_switch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + service: str, + parameter_value: bool, +) -> None: + """Test switch entity turned on/turned off/toggled.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "switch", + service, + blocking=True, + target={"entity_id": "switch.garden_power"}, + ) + + mock_device_client.set_power.assert_awaited_once_with(parameter_value) + + +@pytest.mark.parametrize( + ("service", "exception", "user_error"), + [ + ( + SERVICE_TURN_ON, + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + SERVICE_TURN_OFF, + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_switch_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + service: str, + exception: Exception, + user_error: str, +) -> None: + """Test switch entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_power.side_effect = exception + + assert hass.states.get("switch.garden_power") is not None + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + "switch", + service, + blocking=True, + target={"entity_id": "switch.garden_power"}, + ) diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py new file mode 100644 index 00000000000..e65ea4532e1 --- /dev/null +++ b/tests/components/letpot/test_time.py @@ -0,0 +1,90 @@ +"""Test time entities for the LetPot integration.""" + +from datetime import time +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test time entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.TIME]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test setting the time entity.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=7, minute=0)}, + blocking=True, + target={"entity_id": "time.garden_light_on"}, + ) + + mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_time_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test time entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_light_schedule.side_effect = exception + + assert hass.states.get("time.garden_light_on") is not None + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=7, minute=0)}, + blocking=True, + target={"entity_id": "time.garden_light_on"}, + ) diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json index 0d45dc5c9f4..85ce95da0ed 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -57,6 +57,16 @@ "type": "number" } }, + "filterInfo": { + "filterLifetime": { + "mode": ["r"], + "type": "number" + }, + "usedTime": { + "mode": ["r"], + "type": "number" + } + }, "operation": { "airCleanOperationMode": { "mode": ["w"], @@ -124,6 +134,52 @@ } } }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ], "timer": { "relativeHourToStart": { "mode": ["r", "w"], @@ -149,6 +205,24 @@ "mode": ["r", "w"], "type": "number" } + }, + "windDirection": { + "rotateUpDown": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + }, + "rotateLeftRight": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + } } } } diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json index 90d15d1ae16..8440e7da28c 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/status.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -32,6 +32,19 @@ "targetTemperature": 19, "unit": "C" }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ], + "timer": { "relativeStartTimer": "UNSET", "relativeStopTimer": "UNSET", @@ -39,5 +52,9 @@ "absoluteStopTimer": "UNSET", "absoluteHourToStart": 13, "absoluteMinuteToStart": 14 + }, + "windDirection": { + "rotateUpDown": false, + "rotateLeftRight": false } } diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e9470c3de03..db57e824487 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -20,9 +20,18 @@ 'preset_modes': list([ 'air_clean', ]), + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -43,7 +52,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -72,7 +81,19 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'off', + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), + 'target_temp_high': None, + 'target_temp_low': None, 'target_temp_step': 1, 'temperature': 19, }), diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr index 025f4496aeb..dbb43ce0bb9 100644 --- a/tests/components/lg_thinq/snapshots/test_event.ambr +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr index 68f01854501..ef4d9a21b86 100644 --- a/tests/components/lg_thinq/snapshots/test_number.ambr +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba..5e6eb98ac42 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter remaining', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_filter_lifetime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Filter remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '540', + }) +# --- # name: test_all_entities[sensor.test_air_conditioner_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8,6 +56,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,3 +255,149 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_absolute_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-10-10T13:14:00+00:00', + }) +# --- diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py index 02b91b4771b..e1f1a7ed93d 100644 --- a/tests/components/lg_thinq/test_sensor.py +++ b/tests/components/lg_thinq/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the LG Thinq sensor platform.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -15,6 +16,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time(datetime(2024, 10, 10, tzinfo=UTC)) async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -23,6 +25,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.time_zone = "UTC" with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/life360/test_init.py b/tests/components/life360/test_init.py index 0a781f6f2b2..6bdea177e61 100644 --- a/tests/components/life360/test_init.py +++ b/tests/components/life360/test_init.py @@ -1,7 +1,11 @@ """Tests for the MyQ Connected Services integration.""" from homeassistant.components.life360 import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_life360_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_life360_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index d1a6920f84a..e2a35bcb1b1 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -8,7 +8,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, zeroconf from homeassistant.components.lifx import DOMAIN from homeassistant.components.lifx.config_flow import LifXConfigFlow from homeassistant.components.lifx.const import CONF_SERIAL @@ -16,6 +15,11 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from . import ( @@ -362,7 +366,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ) @@ -385,7 +389,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -402,7 +406,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -416,19 +420,19 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -476,19 +480,19 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), @@ -520,19 +524,19 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC, hostname=LABEL ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "any"}, + properties={ATTR_PROPERTIES_ID: "any"}, type="mock_type", ), ), diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index ffe819fa2cb..58843d63f9a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -25,6 +25,7 @@ from homeassistant.components.lifx.manager import ( SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_SKY, + SERVICE_PAINT_THEME, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -1045,6 +1046,104 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: bulb.set_power.reset_mock() +@pytest.mark.usefixtures("mock_discovery") +async def test_paint_theme_service(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + assert call_dict["value"] in [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ] + assert call_dict["duration"] == 4000 + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TRANSITION: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + hue = round(call_dict["value"][0] / 65535 * 360) + sat = round(call_dict["value"][1] / 65535 * 100) + bri = call_dict["value"][2] >> 8 + kel = call_dict["value"][3] + assert (hue, sat, bri, kel) in [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ] + assert call_dict["duration"] == 6000 + + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + async def test_color_light_with_temp( hass: HomeAssistant, mock_effect_conductor ) -> None: diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 94e12ffbfa5..2a5c9f0bb18 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockLight diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 4e8414edabc..ae54bbd2512 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 776995ee523..5bc17ea3e24 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import frame from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import MockLight @@ -2626,7 +2626,9 @@ def test_filter_supported_color_modes() -> None: assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockLightEntityEntity(light.LightEntity): @@ -2636,6 +2638,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockLightEntityEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass, domain="test", platform_name="test") assert entity.supported_features_compat is light.LightEntityFeature(1) assert "MockLightEntityEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text @@ -2654,7 +2658,7 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_mode( +async def test_report_no_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2670,6 +2674,8 @@ def test_report_no_color_mode( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not report a color mode" assert (expected_warning in caplog.text) is warning_expected @@ -2682,7 +2688,7 @@ def test_report_no_color_mode( (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False), ], ) -def test_report_no_color_modes( +async def test_report_no_color_modes( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2698,6 +2704,8 @@ def test_report_no_color_modes( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = "does not set supported color modes" assert (expected_warning in caplog.text) is warning_expected @@ -2728,7 +2736,7 @@ def test_report_no_color_modes( (light.ColorMode.HS, {light.ColorMode.BRIGHTNESS}, "effect", True), ], ) -def test_report_invalid_color_mode( +async def test_report_invalid_color_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, color_mode: str, @@ -2746,6 +2754,8 @@ def test_report_invalid_color_mode( _attr_supported_color_modes = supported_color_modes entity = MockLightEntityEntity() + platform = MockEntityPlatform(hass, domain="test", platform_name="test") + await platform.async_add_entities([entity]) entity._async_calculate_state() expected_warning = f"set to unsupported color mode {color_mode}" assert (expected_warning in caplog.text) is warning_expected diff --git a/tests/components/linear_garage_door/snapshots/test_cover.ambr b/tests/components/linear_garage_door/snapshots/test_cover.ambr index 96745e1d92a..a09156c53e0 100644 --- a/tests/components/linear_garage_door/snapshots/test_cover.ambr +++ b/tests/components/linear_garage_door/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index c689d04949a..db82f41eb73 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -73,6 +73,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'test-site-name', 'unique_id': None, 'version': 1, diff --git a/tests/components/linear_garage_door/snapshots/test_light.ambr b/tests/components/linear_garage_door/snapshots/test_light.ambr index ba64a2b0a04..9e27efc02ec 100644 --- a/tests/components/linear_garage_door/snapshots/test_light.ambr +++ b/tests/components/linear_garage_door/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py index 640264eb207..2693eda60bb 100644 --- a/tests/components/linear_garage_door/test_init.py +++ b/tests/components/linear_garage_door/test_init.py @@ -5,8 +5,15 @@ from unittest.mock import AsyncMock from linear_garage_door import InvalidLoginError import pytest -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from . import setup_integration @@ -51,3 +58,78 @@ async def test_setup_failure( await setup_integration(hass, mock_config_entry, []) assert mock_config_entry.state == entry_state + + +async def test_repair_issue( + hass: HomeAssistant, + mock_linear: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the Linear Garage Door configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + domain=DOMAIN, + entry_id="acefdd4b3a4a0911067d1cf51414201e", + title="test-site-name", + data={ + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + await setup_integration(hass, config_entry_1, []) + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + entry_id="acefdd4b3a4a0911067d1cf51414201f", + title="test-site-name", + data={ + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + await setup_integration(hass, config_entry_2, []) + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 3fd1fbea95e..adf6aa601ae 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -7,11 +7,11 @@ from linkplay.exceptions import LinkPlayRequestException import pytest from homeassistant.components.linkplay.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import HOST, HOST_REENTRY, NAME, UUID diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 41517acf1e9..975f943d2fa 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.fixture diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index c13fda9068c..de99d701926 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -11,7 +11,7 @@ import pytest from homeassistant import setup from homeassistant.components import automation from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_init_integration diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index 8849392b3dd..d96ce06ca59 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -90,6 +90,14 @@ ROBOT_4_DATA = { "isUSBPowerOn": True, "USBFaultStatus": "CLEAR", "isDFIPartialFull": True, + "isLaserDirty": False, + "surfaceType": "TILE", + "hopperStatus": None, + "scoopsSavedCount": 3769, + "isHopperRemoved": None, + "optimalLitterLevel": 450, + "litterLevelPercentage": 0.7, + "litterLevelState": "OPTIMAL", } FEEDER_ROBOT_DATA = { "id": 1, @@ -142,5 +150,15 @@ FEEDER_ROBOT_DATA = { }, ], } +PET_DATA = { + "petId": "PET-123", + "userId": "1234567", + "createdAt": "2023-04-27T23:26:49.813Z", + "name": "Kitty", + "type": "CAT", + "gender": "FEMALE", + "lastWeightReading": 9.1, + "breeds": ["sphynx"], +} VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 181e4fc1a90..d22c4b2ec49 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -5,14 +5,20 @@ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Robot +from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException import pytest -from homeassistant.components import litterrobot from homeassistant.core import HomeAssistant -from .common import CONFIG, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA +from .common import ( + CONFIG, + DOMAIN, + FEEDER_ROBOT_DATA, + PET_DATA, + ROBOT_4_DATA, + ROBOT_DATA, +) from tests.common import MockConfigEntry @@ -51,6 +57,7 @@ def create_mock_account( skip_robots: bool = False, v4: bool = False, feeder: bool = False, + pet: bool = False, ) -> MagicMock: """Create a mock Litter-Robot account.""" account = MagicMock(spec=Account) @@ -61,6 +68,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) + account.pets = [Pet(PET_DATA, account.session)] if pet else [] return account @@ -82,6 +90,12 @@ def mock_account_with_feederrobot() -> MagicMock: return create_mock_account(feeder=True) +@pytest.fixture +def mock_account_with_pet() -> MagicMock: + """Mock account with Feeder-Robot.""" + return create_mock_account(pet=True) + + @pytest.fixture def mock_account_with_no_robots() -> MagicMock: """Mock a Litter-Robot account.""" @@ -117,22 +131,16 @@ def mock_account_with_side_effects() -> MagicMock: async def setup_integration( hass: HomeAssistant, mock_account: MagicMock, platform_domain: str | None = None ) -> MockConfigEntry: - """Load a Litter-Robot platform with the provided hub.""" + """Load a Litter-Robot platform with the provided coordinator.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.litterrobot.hub.Account", - return_value=mock_account, - ), - patch( - "homeassistant.components.litterrobot.PLATFORMS_BY_TYPE", - {Robot: (platform_domain,)} if platform_domain else {}, - ), + with patch( + "homeassistant.components.litterrobot.coordinator.Account", + return_value=mock_account, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index 69b3f7ce3ab..3fe72aef7e3 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.binary_sensor import ( - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) from homeassistant.const import ATTR_DEVICE_CLASS @@ -21,7 +21,7 @@ async def test_binary_sensors( mock_account: MagicMock, ) -> None: """Tests binary sensors.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, BINARY_SENSOR_DOMAIN) state = hass.states.get("binary_sensor.test_sleeping") assert state.state == "off" diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 9420d3cb8a8..caaf832b780 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -4,9 +4,9 @@ from unittest.mock import patch from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +import pytest from homeassistant import config_entries -from homeassistant.components import litterrobot from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -16,9 +16,8 @@ from .common import CONF_USERNAME, CONFIG, DOMAIN from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_account) -> None: - """Test we get the form.""" - +async def test_full_flow(hass: HomeAssistant, mock_account) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -35,99 +34,59 @@ async def test_form(hass: HomeAssistant, mock_account) -> None: 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"], CONFIG[DOMAIN] ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONFIG[DOMAIN][CONF_USERNAME] - assert result2["data"] == CONFIG[DOMAIN] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] assert len(mock_setup_entry.mock_calls) == 1 async def test_already_configured(hass: HomeAssistant) -> None: - """Test we handle already configured.""" + """Test already configured case.""" MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=CONFIG[litterrobot.DOMAIN], + data=CONFIG[DOMAIN], ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("side_effect", "connect_errors"), + [ + (Exception, {"base": "unknown"}), + (LitterRobotLoginException, {"base": "invalid_auth"}), + (LitterRobotException, {"base": "cannot_connect"}), + ], +) +async def test_create_entry( + hass: HomeAssistant, mock_account, side_effect, connect_errors +) -> None: + """Test creating an entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotLoginException, + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow.""" - entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["errors"] == connect_errors with ( patch( @@ -137,22 +96,22 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: patch( "homeassistant.components.litterrobot.async_setup_entry", return_value=True, - ) as mock_setup_entry, + ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + result["flow_id"], CONFIG[DOMAIN] ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] -async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow fails and recovers.""" +async def test_reauth(hass: HomeAssistant, mock_account: Account) -> None: + """Test reauth flow (with fail and recover).""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) @@ -167,7 +126,7 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.FORM @@ -185,7 +144,7 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, + user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 1c8e0742b26..e42bdb048b7 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException import pytest -from homeassistant.components import litterrobot from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_START, @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .common import CONFIG, VACUUM_ENTITY_ID +from .common import CONFIG, DOMAIN, VACUUM_ENTITY_ID from .conftest import setup_integration from tests.common import MockConfigEntry @@ -57,13 +56,13 @@ async def test_entry_not_setup( ) -> None: """Test being able to handle config entry not setup.""" entry = MockConfigEntry( - domain=litterrobot.DOMAIN, - data=CONFIG[litterrobot.DOMAIN], + domain=DOMAIN, + data=CONFIG[DOMAIN], ) entry.add_to_hass(hass) with patch( - "homeassistant.components.litterrobot.hub.Account.connect", + "homeassistant.components.litterrobot.coordinator.Account.connect", side_effect=side_effect, ): await hass.config_entries.async_setup(entry.entry_id) @@ -91,7 +90,7 @@ async def test_device_remove_devices( dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(litterrobot.DOMAIN, "test-serial", "remove-serial")}, + identifiers={(DOMAIN, "test-serial", "remove-serial")}, ) response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) assert response["success"] diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 48ec1bb06a5..b4902a56e63 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory @@ -26,7 +26,7 @@ async def test_wait_time_select( hass: HomeAssistant, mock_account, entity_registry: er.EntityRegistry ) -> None: """Tests the wait time select entity.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SELECT_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select @@ -41,7 +41,7 @@ async def test_wait_time_select( data[ATTR_OPTION] = wait_time await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, @@ -52,7 +52,7 @@ async def test_wait_time_select( async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> None: """Tests the wait time select entity with invalid value.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, SELECT_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select @@ -61,7 +61,7 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No with pytest.raises(ServiceValidationError): await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, @@ -75,7 +75,7 @@ async def test_panel_brightness_select( entity_registry: er.EntityRegistry, ) -> None: """Tests the wait time select entity.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, SELECT_DOMAIN) select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) assert select @@ -94,7 +94,7 @@ async def test_panel_brightness_select( data[ATTR_OPTION] = option await hass.services.async_call( - PLATFORM_DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, data, blocking=True, diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 360d13096a7..e290d96fcf4 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -104,3 +104,13 @@ async def test_feeder_robot_sensor( sensor = hass.states.get("sensor.test_food_level") assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE + + +async def test_pet_weight_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet weight sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_weight") + assert sensor.state == "9.1" + assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index f18098ccf1d..911dfb3b880 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -8,11 +8,9 @@ from unittest.mock import MagicMock from pylitterbot import Robot import pytest -from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.litterrobot.vacuum import SERVICE_SET_SLEEP_MODE from homeassistant.components.vacuum import ( - ATTR_STATUS, - DOMAIN as PLATFORM_DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_START, SERVICE_STOP, VacuumActivity, @@ -21,7 +19,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir -from .common import VACUUM_ENTITY_ID +from .common import DOMAIN, VACUUM_ENTITY_ID from .conftest import setup_integration VACUUM_UNIQUE_ID = "LR3C012345-litter_box" @@ -35,49 +33,33 @@ async def test_vacuum( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock ) -> None: """Tests the vacuum entity was set up.""" - entity_registry.async_get_or_create( - PLATFORM_DOMAIN, + VACUUM_DOMAIN, DOMAIN, VACUUM_UNIQUE_ID, - suggested_object_id=VACUUM_ENTITY_ID.replace(PLATFORM_DOMAIN, ""), + suggested_object_id=VACUUM_ENTITY_ID.replace(VACUUM_DOMAIN, ""), ) ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - assert len(entity_registry.entities) == 1 + await setup_integration(hass, mock_account, VACUUM_DOMAIN) assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum assert vacuum.state == VacuumActivity.DOCKED - assert vacuum.attributes["is_sleeping"] is False ent_reg_entry = entity_registry.async_get(VACUUM_ENTITY_ID) assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID -async def test_vacuum_status_when_sleeping( - hass: HomeAssistant, mock_account_with_sleeping_robot: MagicMock -) -> None: - """Tests the vacuum status when sleeping.""" - await setup_integration(hass, mock_account_with_sleeping_robot, PLATFORM_DOMAIN) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.attributes.get(ATTR_STATUS) == "Ready (Sleeping)" - - async def test_no_robots( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account_with_no_robots: MagicMock, ) -> None: """Tests the vacuum entity was set up.""" - entry = await setup_integration(hass, mock_account_with_no_robots, PLATFORM_DOMAIN) - - assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) + entry = await setup_integration(hass, mock_account_with_no_robots, VACUUM_DOMAIN) assert len(entity_registry.entities) == 0 @@ -89,7 +71,7 @@ async def test_vacuum_with_error( hass: HomeAssistant, mock_account_with_error: MagicMock ) -> None: """Tests a vacuum entity with an error.""" - await setup_integration(hass, mock_account_with_error, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_error, VACUUM_DOMAIN) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum @@ -114,7 +96,7 @@ async def test_activities( expected_state: str, ) -> None: """Test sending commands to the switch.""" - await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account_with_litterrobot_4, VACUUM_DOMAIN) robot: Robot = mock_account_with_litterrobot_4.robots[0] robot._update_data(robot_data, partial=True) @@ -147,7 +129,7 @@ async def test_commands( issue_registry: ir.IssueRegistry, ) -> None: """Test sending commands to the vacuum.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, VACUUM_DOMAIN) vacuum = hass.states.get(VACUUM_ENTITY_ID) assert vacuum @@ -158,7 +140,7 @@ async def test_commands( issues = extra.get("issues", set()) await hass.services.async_call( - COMPONENT_SERVICE_DOMAIN.get(service, PLATFORM_DOMAIN), + COMPONENT_SERVICE_DOMAIN.get(service, VACUUM_DOMAIN), service, data, blocking=True, diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 61908faeca6..0720e6d7ded 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -8,7 +8,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( FRIENDLY_NAME, diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index ddfdf4249bd..0eb48aa3060 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -281,7 +281,7 @@ async def test_import_from_yaml_fails( assert not hass.states.get("camera.config_test") issue = issue_registry.async_get_issue( - DOMAIN, f"no_access_path_{slugify("mock.file")}" + DOMAIN, f"no_access_path_{slugify('mock.file')}" ) assert issue assert issue.translation_key == "no_access_path" diff --git a/tests/components/lock/conftest.py b/tests/components/lock/conftest.py index fd569b162bc..254a59cae0d 100644 --- a/tests/components/lock/conftest.py +++ b/tests/components/lock/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.lock import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -120,7 +120,7 @@ async def setup_lock_platform_test_entity( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test lock platform via config entry.""" async_add_entities([entity]) diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3ecdf2a9bca..7d1c39d10f0 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 68af8c7d482..510034a2172 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.lock import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index abb118467f4..b303a34e151 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -16,7 +16,7 @@ from homeassistant.components.recorder.models import ( from homeassistant.core import Context from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util IDX_TO_NAME = dict(enumerate(EventAsRow._fields)) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 841c8ed1247..c62bdcaa824 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -10,6 +10,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import logbook, recorder # pylint: disable-next=hass-component-root-import @@ -40,12 +41,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockRow, mock_humanify diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 50139d0f4f7..7b2550ccc82 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -37,7 +37,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index cea0f969893..64dc1220683 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote -from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo DEVICE_ID = "98F33163" MODULE = "homeassistant.components.lookin" diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index d59ed60796b..6f7da09fa0d 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -8,16 +8,16 @@ import aiohttp from loqedAPI import loqed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -zeroconf_data = zeroconf.ZeroconfServiceInfo( +zeroconf_data = ZeroconfServiceInfo( ip_address=ip_address("192.168.12.34"), ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 14d93d8302f..f35f7369f93 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -7,21 +7,12 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator -@pytest.fixture -def mock_onboarding_not_done() -> Generator[MagicMock]: - """Mock that Home Assistant is currently onboarding.""" - with patch( - "homeassistant.components.onboarding.async_is_onboarded", - return_value=False, - ) as mock_onboarding: - yield mock_onboarding - - @pytest.fixture def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" @@ -32,15 +23,6 @@ def mock_onboarding_done() -> Generator[MagicMock]: yield mock_onboarding -@pytest.fixture -def mock_add_onboarding_listener() -> Generator[MagicMock]: - """Mock that Home Assistant is currently onboarding.""" - with patch( - "homeassistant.components.onboarding.async_add_listener", - ) as mock_add_onboarding_listener: - yield mock_add_onboarding_listener - - async def test_create_dashboards_when_onboarded( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -59,40 +41,36 @@ async def test_create_dashboards_when_onboarded( assert response["result"] == [] -async def test_create_dashboards_when_not_onboarded( +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_hass_data_compatibility( hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - hass_storage: dict[str, Any], - mock_add_onboarding_listener, - mock_onboarding_not_done, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test we automatically create dashboards when not onboarded.""" - client = await hass_ws_client(hass) + """Test compatibility for external access. + + See: + https://github.com/hacs/integration/blob/4a820e8b1b066bc54a1c9c61102038af6c030603 + /custom_components/hacs/repositories/plugin.py#L173 + """ + expected_prefix = ( + "Detected that custom integration 'my_integration' accessed lovelace_data" + ) assert await async_setup_component(hass, "lovelace", {}) - # Call onboarding listener - mock_add_onboarding_listener.mock_calls[0][1][1]() - await hass.async_block_till_done() + assert (lovelace_data := hass.data.get("lovelace")) is not None - # List dashboards - await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == [ - { - "icon": "mdi:map", - "id": "map", - "mode": "storage", - "require_admin": False, - "show_in_sidebar": True, - "title": "Map", - "url_path": "map", - } - ] + # Direct access to resources is fine + assert lovelace_data.resources is not None + assert expected_prefix not in caplog.text - # List map dashboard config - await client.send_json_auto_id({"type": "lovelace/config", "url_path": "map"}) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {"strategy": {"type": "map"}} + # Dict compatibility logs warning + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert lovelace_data["resources"] is not None + assert f"{expected_prefix}['resources']" in caplog.text + + # Dict get compatibility logs warning + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert lovelace_data.get("resources") is not None + assert f"{expected_prefix}.get('resources')" in caplog.text diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index 47b2a4891cf..df861fafffe 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -6,11 +6,11 @@ from urllib.error import HTTPError import pytest -from homeassistant.components.lutron.const import DOMAIN +from homeassistant.components.lutron.const import CONF_DEFAULT_DIMMER_LEVEL, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry @@ -146,3 +146,41 @@ MOCK_DATA_IMPORT = { CONF_USERNAME: "lutron", CONF_PASSWORD: "integration", } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + unique_id="12345678901", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Try to set an out of range dimmer level (260) + out_of_range_level = 260 + + # The voluptuous validation will raise an exception before the handler processes it + with pytest.raises(InvalidData): + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: out_of_range_level}, + ) + + # Now try with a valid value + valid_level = 100 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: valid_level}, + ) + + # Verify that the flow finishes successfully with the valid value + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_DEFAULT_DIMMER_LEVEL: valid_level} diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index b2edaa07155..bdbe6501470 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,9 +10,10 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf -from homeassistant.components.lutron_caseta import DOMAIN -import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow +from homeassistant.components.lutron_caseta import ( + DOMAIN, + config_flow as CasetaConfigFlow, +) from homeassistant.components.lutron_caseta.const import ( CONF_CA_CERTS, CONF_CERTFILE, @@ -23,6 +24,7 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ENTRY_MOCK_DATA, MockBridge @@ -421,7 +423,7 @@ async def test_zeroconf_host_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -449,7 +451,7 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", @@ -472,7 +474,7 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", @@ -500,7 +502,7 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", diff --git a/tests/components/madvr/snapshots/test_binary_sensor.ambr b/tests/components/madvr/snapshots/test_binary_sensor.ambr index 7fd54a7c240..7d665210a6f 100644 --- a/tests/components/madvr/snapshots/test_binary_sensor.ambr +++ b/tests/components/madvr/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr index 3a281391860..92d0578dba8 100644 --- a/tests/components/madvr/snapshots/test_diagnostics.ambr +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'envy', 'unique_id': '00:11:22:33:44:55', 'version': 1, diff --git a/tests/components/madvr/snapshots/test_remote.ambr b/tests/components/madvr/snapshots/test_remote.ambr index 1157496a93e..c90270674c8 100644 --- a/tests/components/madvr/snapshots/test_remote.ambr +++ b/tests/components/madvr/snapshots/test_remote.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/madvr/snapshots/test_sensor.ambr b/tests/components/madvr/snapshots/test_sensor.ambr index 7b0dd254f77..115f6a3f5d7 100644 --- a/tests/components/madvr/snapshots/test_sensor.ambr +++ b/tests/components/madvr/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -243,6 +248,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -348,6 +355,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +413,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -462,6 +471,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -520,6 +530,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -583,6 +594,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -639,6 +651,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -685,6 +698,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -736,6 +750,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -789,6 +804,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -838,6 +854,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -884,6 +901,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +948,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -982,6 +1001,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1039,6 +1059,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1097,6 +1118,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1160,6 +1182,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1216,6 +1239,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1262,6 +1286,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1313,6 +1338,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/madvr/test_binary_sensor.py b/tests/components/madvr/test_binary_sensor.py index 469a3225ca0..9ddbc7b3afe 100644 --- a/tests/components/madvr/test_binary_sensor.py +++ b/tests/components/madvr/test_binary_sensor.py @@ -9,7 +9,7 @@ from syrupy import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py index 6fc507534d6..1ddbacdb6e9 100644 --- a/tests/components/madvr/test_remote.py +++ b/tests/components/madvr/test_remote.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .const import ( diff --git a/tests/components/madvr/test_sensor.py b/tests/components/madvr/test_sensor.py index ddc01fc737a..dd1722913f2 100644 --- a/tests/components/madvr/test_sensor.py +++ b/tests/components/madvr/test_sensor.py @@ -10,7 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.madvr.sensor import get_temperature from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 9fc92cd5458..941d7523220 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component, mock_restore_cache from tests.components.alarm_control_panel import common diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 2b401cb10a0..9bb506b935a 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 0ad27cde29b..25231c15a32 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -155,7 +155,7 @@ async def test_service_say_http_error( await retrieve_media( hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) mock_speak.assert_called_once() diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 37fa765acea..28157b9e6eb 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index c8df8cdab19..22ac2671c36 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py index ab2d7456baf..4242f88d34a 100644 --- a/tests/components/mastodon/test_notify.py +++ b/tests/components/mastodon/test_notify.py @@ -2,10 +2,13 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonAPIError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -36,3 +39,27 @@ async def test_notify( ) assert mock_mastodon_client.status_post.assert_called_once + + +async def test_notify_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the notify raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + NOTIFY_DOMAIN, + "trwnh_mastodon_social", + { + "message": "test toot", + }, + blocking=True, + return_response=False, + ) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py new file mode 100644 index 00000000000..4dafa9a8e5b --- /dev/null +++ b/tests/components/mastodon/test_services.py @@ -0,0 +1,263 @@ +"""Tests for the Mastodon services.""" + +from unittest.mock import AsyncMock, Mock, patch + +from mastodon.Mastodon import MastodonAPIError +import pytest + +from homeassistant.components.mastodon.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_MEDIA_DESCRIPTION, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + { + "status": "test toot", + "spoiler_text": None, + "visibility": None, + "media_ids": None, + "sensitive": None, + }, + ), + ( + {ATTR_STATUS: "test toot", ATTR_VISIBILITY: "private"}, + { + "status": "test toot", + "spoiler_text": None, + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_VISIBILITY: "private", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + ATTR_MEDIA_DESCRIPTION: "A test image", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_service_post( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service.""" + + await setup_integration(hass, mock_config_entry) + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + } + | payload, + blocking=True, + return_response=False, + ) + + mock_mastodon_client.status_post.assert_called_with(**kwargs) + + mock_mastodon_client.status_post.reset_mock() + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + {"status": "test toot", "spoiler_text": None, "visibility": None}, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "media_description": None, + "sensitive": None, + }, + ), + ], +) +async def test_post_service_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config.is_allowed_path = Mock(return_value=True) + mock_mastodon_client.media_post.return_value = {"id": "1"} + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_media_upload_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because media upload fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + mock_mastodon_client.media_post.side_effect = MastodonAPIError + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises(HomeAssistantError, match="Unable to upload image /fail.jpg"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_path_not_whitelisted( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because the file path is not whitelisted.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + with pytest.raises( + HomeAssistantError, match="/fail.jpg is not a whitelisted directory" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_service_entry_availability( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot"} + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, + blocking=True, + return_response=False, + ) + + with pytest.raises( + ServiceValidationError, match='Integration "mastodon" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, + blocking=True, + return_response=False, + ) diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py index dabee74fdc3..ea0805b920a 100644 --- a/tests/components/matrix/test_commands.py +++ b/tests/components/matrix/test_commands.py @@ -42,9 +42,8 @@ class CommandTestParameters: Commands that are named with 'Subset' are expected not to be read from Room A. """ - if ( - self.expected_event_data_extra is None - or "Subset" in self.expected_event_data_extra["command"] + if self.expected_event_data_extra is None or ( + "Subset" in self.expected_event_data_extra["command"] and self.room_id not in SUBSET_ROOMS ): return None diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index bbafec48e10..d7429f6087d 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,6 +104,7 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_laundrywasher", "smoke_detector", "switch_unit", "temperature_sensor", @@ -115,6 +116,7 @@ async def integration_fixture( "window_covering_pa_lift", "window_covering_pa_tilt", "window_covering_tilt", + "yandex_smart_socket", ] ) async def matter_devices( diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json new file mode 100644 index 00000000000..3b1ed0043de --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -0,0 +1,909 @@ +{ + "node_id": 29, + "date_commissioned": "2024-10-19T19:49:36.900186", + "last_interview": "2024-10-20T09:26:38.517535", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 52, 53, 60, 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "LaundryWasher", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "DC840FF79F5DBFCE", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/45/0": 1, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "GstaSerJSho=", + "5": [], + "6": [ + "/cS6oCynAAGilSC/p+bVSg==", + "/QANuACgAAAAAAD//gDIAA==", + "/QANuACgAABL3TOUNF1NGw==", + "/oAAAAAAAAAYy1pJ6slKGg==" + ], + "7": 4 + } + ], + "0/51/1": 10, + "0/51/2": 1934, + "0/51/3": 17, + "0/51/4": 6, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 8, + "1": "shell", + "3": 324 + }, + { + "0": 3, + "1": "UART", + "3": 127 + }, + { + "0": 2, + "1": "OT Stack", + "3": 719 + }, + { + "0": 9, + "1": "LaundryW", + "3": 767 + }, + { + "0": 12, + "1": "Bluetoot", + "3": 174 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 294 + }, + { + "0": 11, + "1": "Bluetoot", + "3": 216 + }, + { + "0": 6, + "1": "Tmr Svc", + "3": 586 + }, + { + "0": 5, + "1": "IDLE", + "3": 264 + }, + { + "0": 7, + "1": "CHIP", + "3": 699 + } + ], + "0/52/1": 99808, + "0/52/2": 17592, + "0/52/3": 4294959166, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 7100699097952925053, + "1": 23, + "2": 15360, + "3": 222256, + "4": 71507, + "5": 2, + "6": -83, + "7": -90, + "8": 56, + "9": 3, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9656160343072683744, + "1": 16, + "2": 17408, + "3": 211448, + "4": 95936, + "5": 3, + "6": -53, + "7": -59, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 5926511551178228101, + "1": 0, + "2": 19456, + "3": 420246, + "4": 89821, + "5": 3, + "6": -57, + "7": -56, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3029834005214616809, + "1": 8, + "2": 22528, + "3": 125241, + "4": 91286, + "5": 3, + "6": -73, + "7": -81, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17459145101989614194, + "1": 7, + "2": 26624, + "3": 1426216, + "4": 36884, + "5": 3, + "6": -39, + "7": -39, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17503311195895696084, + "1": 30, + "2": 29696, + "3": 577028, + "4": 98083, + "5": 2, + "6": -84, + "7": -85, + "8": 65, + "9": 20, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8241705229565301122, + "1": 19, + "2": 57344, + "3": 488092, + "4": 55364, + "5": 3, + "6": -48, + "7": -48, + "8": 1, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 7100699097952925053, + "1": 15360, + "2": 15, + "3": 22, + "4": 1, + "5": 2, + "6": 2, + "7": 23, + "8": true, + "9": true + }, + { + "0": 9656160343072683744, + "1": 17408, + "2": 17, + "3": 19, + "4": 1, + "5": 3, + "6": 3, + "7": 16, + "8": true, + "9": true + }, + { + "0": 5926511551178228101, + "1": 19456, + "2": 19, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 0, + "8": true, + "9": true + }, + { + "0": 3029834005214616809, + "1": 22528, + "2": 22, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 8, + "8": true, + "9": true + }, + { + "0": 17459145101989614194, + "1": 26624, + "2": 26, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 7, + "8": true, + "9": true + }, + { + "0": 17503311195895696084, + "1": 29696, + "2": 29, + "3": 26, + "4": 1, + "5": 2, + "6": 2, + "7": 30, + "8": true, + "9": true + }, + { + "0": 0, + "1": 51200, + "2": 50, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + }, + { + "0": 8241705229565301122, + "1": 57344, + "2": 56, + "3": 17, + "4": 1, + "5": 3, + "6": 3, + "7": 19, + "8": true, + "9": true + } + ], + "0/53/9": 1348153998, + "0/53/10": 68, + "0/53/11": 49, + "0/53/12": 120, + "0/53/13": 56, + "0/53/14": 1, + "0/53/15": 0, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 18798, + "0/53/23": 18683, + "0/53/24": 115, + "0/53/25": 18699, + "0/53/26": 18492, + "0/53/27": 115, + "0/53/28": 18814, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 15745, + "0/53/34": 207, + "0/53/35": 0, + "0/53/36": 71, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 7183, + "0/53/40": 6295, + "0/53/41": 886, + "0/53/42": 6140, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 1041, + "0/53/50": 0, + "0/53/51": 2, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRHRgkBwEkCAEwCUEEJu8N93WFULw4vts483kDAExYc3VhKuaWdmpdJnF5pDcls+y34i6RfchubiU77BJq8zo9VGn6J59mVROTzKgr0DcKNQEoARgkAgE2AwQCBAEYMAQUbJ+53QmsxXf2iP0oL4td/BQFi0gwBRRT9HTfU5Nds+HA8j+/MRP+0pVyIxgwC0BFzpzN0Z0DdN+oPUwK87jzZ8amzJxWlmbnW/Q+j1Z4ziWsFy3yLAsgKYL4nOexZZSqvlEvzMhpstndmh1eGYZfGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEyT62Yt4qMI+MorlmQ/Hxh2CpLetznVknlAbhvYAwTexpSxp9GnhR09SrcUhz3mOb0eZa2TylqcnPBhHJ2Ih2RTcKNQEpARgkAmAwBBRT9HTfU5Nds+HA8j+/MRP+0pVyIzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQI/Kc38hQyK7AkT7/pN4hiYW3LoWKT3NA43+ssMJoVpDcaZ989GXBQKIbHKbBEXzUQ1J8wfL7l2pL0Z8Lso9JwgY", + "254": 4 + } + ], + "0/62/1": [ + { + "1": "BIrruNo7r0gX6j6lq1dDi5zeK3jxcTavjt2o4adCCSCYtbxOakfb7C3GXqgV4LzulFSinbewmYkdqFBHqm5pxvU=", + "2": 4939, + "3": 2, + "4": 29, + "5": "", + "254": 4 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBP2G+CskBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQIrLt7Uq3S9HEe7apdzYSR+j3BLWNXSTLWD4YbrdyYLpm6xqHDV/NPARcIp4skZdtz91WwFBDfuS4jO5aVoER1sY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE0j40vjcb6ZsmtBR/I0rB3ZIfAA8lPeWCTxG7nPSbNpepe18XwLidhFIHKmvtZWDZ3Hl3MM9NBB+LAZlCFq/edjcKNQEpARgkAmAwBBS7EfW886qYxvWeWjpA/G/CjDuwEDAFFLsR9bzzqpjG9Z5aOkD8b8KMO7AQGDALQIgQgt5asUGXO0ZyTWWKdjAmBSoJAzRMuD4Z+tQYZanQ3s0OItL07MU2In6uyXhjNBfjJlRqon780lhjTsm2Y+8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEiuu42juvSBfqPqWrV0OLnN4rePFxNq+O3ajhp0IJIJi1vE5qR9vsLcZeqBXgvO6UVKKdt7CZiR2oUEeqbmnG9TcKNQEpARgkAmAwBBTjAjvCZO2QpJyarhRj7T8yYjarAzAFFOMCO8Jk7ZCknJquFGPtPzJiNqsDGDALQE7hTxTRg92QOxwA1hK3xv8DaxvxL71r6ZHcNRzug9wNnonJ+NC84SFKvKDxwcBxHYqFdIyDiDgwJNTQIBgasmIY" + ], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 115, + "1": 1 + } + ], + "1/29/1": [3, 29, 30, 81, 83, 86, 96], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/81/0": null, + "1/81/1": null, + "1/81/65532": null, + "1/81/65533": 2, + "1/81/65528": [1], + "1/81/65529": [0], + "1/81/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/83/0": ["Off", "Low", "Medium", "High"], + "1/83/1": 0, + "1/83/2": 0, + "1/83/3": [0, 1], + "1/83/65532": 3, + "1/83/65533": 1, + "1/83/65528": [], + "1/83/65529": [], + "1/83/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/86/0": 0, + "1/86/1": 0, + "1/86/2": 0, + "1/86/3": 0, + "1/86/4": 1, + "1/86/5": ["Cold", "Colors", "Whites"], + "1/86/65532": 2, + "1/86/65533": 1, + "1/86/65528": [], + "1/86/65529": [0], + "1/86/65531": [4, 5, 65528, 65529, 65531, 65532, 65533], + "1/96/0": ["pre-soak", "rinse", "spin"], + "1/96/1": 0, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + } + ], + "1/96/4": 0, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 1, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1296, + "1": 1 + } + ], + "2/29/1": [29, 144, 145, 156], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 2, + "2/144/1": 3, + "2/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "2/144/3": [ + { + "0": 0, + "1": 0, + "2": 300, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + }, + { + "0": 1, + "1": 0, + "2": 500, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + }, + { + "0": 2, + "1": 0, + "2": 1000, + "7": 129, + "8": 129, + "9": 129, + "10": 129 + } + ], + "2/144/4": 120000, + "2/144/5": 0, + "2/144/6": 0, + "2/144/7": 0, + "2/144/8": 0, + "2/144/9": 0, + "2/144/10": 0, + "2/144/11": 120000, + "2/144/12": 0, + "2/144/13": 0, + "2/144/14": 60, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": 9800, + "2/144/18": 0, + "2/144/65532": 31, + "2/144/65533": 1, + "2/144/65528": [], + "2/144/65529": [], + "2/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "2/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 0, + "1": 1000000000000000, + "2": 500, + "3": 50 + } + ] + }, + "2/145/1": { + "0": 0, + "1": 1900, + "2": 1936, + "3": 1900222, + "4": 1936790 + }, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 5, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 5, 65528, 65529, 65531, 65532, 65533], + "2/156/0": [0, 1, 2], + "2/156/1": null, + "2/156/65532": 12, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/yandex_smart_socket.json b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json new file mode 100644 index 00000000000..26cdf38414f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json @@ -0,0 +1,278 @@ +{ + "node_id": 4, + "date_commissioned": "2024-12-05T10:54:31.635203", + "last_interview": "2024-12-05T12:16:52.038776", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Yandex", + "0/40/2": 5130, + "0/40/3": "YNDX-00540", + "0/40/4": 540, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v0.4", + "0/40/9": 18, + "0/40/10": "8.0.r13402545-18", + "0/40/15": "HP000RM000V4RW", + "0/40/17": true, + "0/40/18": "E4480D32A5480B29", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 17, 18, 19, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "**REDACTED**", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "PAtP8Nse", + "5": ["wKgAEw=="], + "6": ["/oAAAAAAAAA+C0///vDbHg==", "/YrmoeskHZU+C0///vDbHg=="], + "7": 1 + } + ], + "0/51/1": 4, + "0/51/2": 124, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/1": 79260, + "0/52/2": 171268, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "eJoYDvok", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -53, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEEvOt9COzrjgf+b8q6FcKeKfbtqJybToVtEF0jiidbqg8FPmTIPTm1kU9hEiE6sd2N/GWSQHRoMi3YNl19h1PM3zcKNQEoARgkAgE2AwQCBAEYMAQUSu0+nQ/nOzrUNECyeBAqGPVu33YwBRS2PEiS/N109emRL3DTMaiWoWrEShgwC0DoGPCGt0HeGYnTS4TS2R7vbNhiFuuIrUQuxY5phP/UXBZosBDQTsnRTbMof18OkeO68MEcLXdIXjBJvBDaP/TsGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEz8nO5tz0gMFM5TW4YjYXGxkhr/UKHZg1rCa21StYqGd0wGaP7a5eMR+2BY20D1b11R7i6teWKnAaW+WqY0vQTjcKNQEpARgkAmAwBBS2PEiS/N109emRL3DTMaiWoWrESjAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQGFBsf7Ecq44e7NN8dCZIoJMUG16rmwD4ZtHtD4JPTxYabEreeblNF2ZDSgbo+A8sfz7Ci37WjznxbEj96vR8MgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BLB0QnDldRPfV2xt6Nd/34ja8uaWwvsLYZsF3yCdIwyB/krYZ0u1uBS0FTo7E3iqvN0cDZ7fbhw0OUsKTVZ9Y10=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYU3a/iASYVn7wdXBgmBKaW1SwkBQA3BiYU3a/iASYVn7wdXBgkBwEkCAEwCUEE/OyhHiUZDgJ7iUVCKouxsZgI0DGBcK8E+vbDIHD5gfeFPNuT5sXN8aHlsEl7fZhfjbdEbIFudeJKIr5uf7+PLTcKNQEpARgkAmAwBBQtr6wAOFJ7UJLwYUKvomZh5wPaszAFFC2vrAA4UntQkvBhQq+iZmHnA9qzGDALQM5/1ziQdNcMURJqGH+j9wt7w/wPyeq8zf+u3FGgmmfhBSouJw4f+TIJLk7m/eQD0p2Q5rSDEuuwI2VBTxxeuWgY", + "FTABAQAkAgE3AycUe5hjm9Wdt4YmFewk8wUYJgTGN00tJAUANwYnFHuYY5vVnbeGJhXsJPMFGCQHASQIATAJQQR56PnGPW5p1dXhHDSVnjoah8C2+JYHzPAm5tvYgup9gf7DukH2TxxLdDEaBdD4hgQj/R8hrMYSmj8XmHQ8HhdZNwo1ASkBGCQCYDAEFN8wYcjYskj9OSQoEXkOn0QmWDrkMAUU3zBhyNiySP05JCgReQ6fRCZYOuQYMAtA+j7ir4H1KYIxAe49jhZr/Gg7pDUKtIcYyUVJD0g9egIYHShM1y1j3BsOQTBX6mnLPp4FS4AtNsUgaM+XPKSFSxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEsHRCcOV1E99XbG3o13/fiNry5pbC+wthmwXfIJ0jDIH+SthnS7W4FLQVOjsTeKq83RwNnt9uHDQ5SwpNVn1jXTcKNQEpARgkAmAwBBRv/3RUuVdGOv0HUkHAvs+4nSiniTAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQKrvVhoinxo07C2nI/zakt4xUZKgab6DVI4mBXYoPQXaZM8jmEqWboPnLBUGbr9UAnqEc9yARHwlC77eXN1BCdUY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEMmvMdDf/h+u7fawdjIe6gXEeWuszCShR8ulsHMLnJYTMHVrkztOcj4cHw6haH/q909aVmL3xLlbEC2lZtmZClDcKNQEpARgkAmAwBBRoZjEcSXeh6IFBtW0A2OilJBdeYjAFFGhmMRxJd6HogUG1bQDY6KUkF15iGDALQJm5+/SkVrR4iBpGVqZZGOH+DpS+cQYqceN1+JSnDFwxJe+khYxFifMohSQ5NLlTiJQTZWYpqMKMZHT36pWWADUY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 2 + } + ], + "1/29/1": [3, 4, 6, 29, 2820, 336264194, 336264195], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/2820/0": 9, + "1/2820/1285": 2170, + "1/2820/1288": 592, + "1/2820/1291": 560, + "1/2820/1536": 1, + "1/2820/1537": 10, + "1/2820/1538": 1, + "1/2820/1539": 1000, + "1/2820/1540": 1, + "1/2820/1541": 8, + "1/2820/2049": 2530, + "1/2820/2050": 16300, + "1/2820/65532": 0, + "1/2820/65533": 3, + "1/2820/65528": [], + "1/2820/65529": [], + "1/2820/65531": [ + 0, 1285, 1288, 1291, 1536, 1537, 1538, 1539, 1540, 1541, 2049, 2050, + 65528, 65529, 65531, 65532, 65533 + ], + "1/336264194/336199680": 44, + "1/336264194/336199681": 0, + "1/336264194/336199682": 0, + "1/336264194/336199698": 70, + "1/336264194/65532": 0, + "1/336264194/65533": 1, + "1/336264194/65528": [], + "1/336264194/65529": [], + "1/336264194/65531": [ + 65528, 65529, 65531, 336199680, 336199681, 336199682, 336199698, 65532, + 65533 + ], + "1/336264195/336199680": 0, + "1/336264195/65532": 0, + "1/336264195/65533": 1, + "1/336264195/65528": [], + "1/336264195/65529": [], + "1/336264195/65531": [65528, 65529, 65531, 336199680, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 82dcc166f13..c8de905d03f 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +486,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +534,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +581,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -616,6 +629,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 10792b58d28..448136eeed2 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1,239 +1,4 @@ # serializer version: 1 -# name: test_buttons[air_purifier][button.air_purifier_identify_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (1)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (2)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (2)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_3-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (3)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-3-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (3)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_4-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (4)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-4-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (4)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.air_purifier_identify_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (5)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (5)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[air_purifier][button.air_purifier_reset_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -241,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,53 +93,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[air_quality_sensor][button.lightfi_aq1_air_quality_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.lightfi_aq1_air_quality_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_quality_sensor][button.lightfi_aq1_air_quality_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'lightfi-aq1-air-quality-sensor Identify', - }), - 'context': , - 'entity_id': 'button.lightfi_aq1_air_quality_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[color_temperature_light][button.mock_color_temperature_light_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -380,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -427,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -449,7 +171,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -467,100 +189,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[door_lock][button.mock_door_lock_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_door_lock_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[door_lock][button.mock_door_lock_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Door Lock Identify', - }), - 'context': , - 'entity_id': 'button.mock_door_lock_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[door_lock_with_unbolt][button.mock_door_lock_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_door_lock_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[door_lock_with_unbolt][button.mock_door_lock_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Door Lock Identify', - }), - 'context': , - 'entity_id': 'button.mock_door_lock_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[eve_contact_sensor][button.eve_door_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -568,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -590,7 +219,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -615,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -637,7 +267,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -662,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,7 +315,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -709,6 +340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,7 +363,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -756,6 +388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -778,7 +411,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -803,6 +436,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -825,7 +459,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -850,6 +484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -872,7 +507,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -897,6 +532,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -919,7 +555,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -937,335 +573,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[flow_sensor][button.mock_flow_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_flow_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[flow_sensor][button.mock_flow_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Flow Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_flow_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch][button.mock_generic_switch_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_generic_switch_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch][button.mock_generic_switch_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Identify', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_fancy_button-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_generic_switch_fancy_button', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fancy Button', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_fancy_button-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Fancy Button', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_fancy_button', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_identify_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_generic_switch_identify_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Identify (1)', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[humidity_sensor][button.mock_humidity_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_humidity_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[humidity_sensor][button.mock_humidity_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Humidity Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_humidity_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[light_sensor][button.mock_light_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_light_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[light_sensor][button.mock_light_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Light Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_light_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[microwave_oven][button.microwave_oven_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.microwave_oven_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[microwave_oven][button.microwave_oven_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Microwave Oven Identify', - }), - 'context': , - 'entity_id': 'button.microwave_oven_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[microwave_oven][button.microwave_oven_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1273,6 +580,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1319,6 +627,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1365,6 +674,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1411,6 +721,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1457,6 +768,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1479,7 +791,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1504,6 +816,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1526,7 +839,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1551,6 +864,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1573,7 +887,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1598,6 +912,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1620,7 +935,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1645,6 +960,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1667,7 +983,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1692,6 +1008,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1714,7 +1031,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1739,6 +1056,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1761,7 +1079,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1786,6 +1104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1808,7 +1127,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1826,147 +1145,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[onoff_light][button.mock_onoff_light_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_onoff_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light][button.mock_onoff_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock OnOff Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_onoff_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[onoff_light_alt_name][button.mock_onoff_light_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_onoff_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light_alt_name][button.mock_onoff_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock OnOff Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_onoff_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[onoff_light_no_name][button.mock_light_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light_no_name][button.mock_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[onoff_light_with_levelcontrol_present][button.d215s_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1974,6 +1152,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1996,7 +1175,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2014,147 +1193,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[pressure_sensor][button.mock_pressure_sensor_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_pressure_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[pressure_sensor][button.mock_pressure_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Pressure Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_pressure_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.room_airconditioner_identify_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Room AirConditioner Identify (1)', - }), - 'context': , - 'entity_id': 'button.room_airconditioner_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.room_airconditioner_identify_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (2)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Room AirConditioner Identify (2)', - }), - 'context': , - 'entity_id': 'button.room_airconditioner_identify_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[silabs_dishwasher][button.dishwasher_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2162,6 +1200,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2184,7 +1223,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2209,6 +1248,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2255,6 +1295,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2301,6 +1342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2340,6 +1382,242 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundrywasher_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'LaundryWasher Identify', + }), + 'context': , + 'entity_id': 'button.laundrywasher_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_pause', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pause', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStatePauseButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Pause', + }), + 'context': , + 'entity_id': 'button.laundrywasher_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_resume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_resume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resume', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateResumeButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_resume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Resume', + }), + 'context': , + 'entity_id': 'button.laundrywasher_resume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStartButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Start', + }), + 'context': , + 'entity_id': 'button.laundrywasher_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.laundrywasher_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateStopButton-96-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_laundrywasher][button.laundrywasher_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Stop', + }), + 'context': , + 'entity_id': 'button.laundrywasher_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[smoke_detector][button.smoke_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2347,6 +1625,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2369,7 +1648,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2394,6 +1673,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2416,7 +1696,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2441,6 +1721,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2463,7 +1744,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-0-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2488,6 +1769,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2510,7 +1792,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2528,147 +1810,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[valve][button.valve_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.valve_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[valve][button.valve_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Valve Identify', - }), - 'context': , - 'entity_id': 'button.valve_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_full][button.mock_full_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_full_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_full][button.mock_full_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Full Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_full_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_lift_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Lift Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_lift_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2676,6 +1817,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2698,7 +1840,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2716,19 +1858,20 @@ 'state': 'unknown', }) # --- -# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_identify-entry] +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.mock_pa_tilt_window_covering_identify', + 'entity_id': 'button.yndx_00540_identify', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2745,65 +1888,18 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- -# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_identify-state] +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'identify', - 'friendly_name': 'Mock PA Tilt Window Covering Identify', + 'friendly_name': 'YNDX-00540 Identify', }), 'context': , - 'entity_id': 'button.mock_pa_tilt_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_identify-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_tilt_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Tilt Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_tilt_window_covering_identify', + 'entity_id': 'button.yndx_00540_identify', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 25f5ca06f62..8aeb1aaafdd 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,6 +76,7 @@ 'min_temp': 10.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -141,6 +143,7 @@ 'min_temp': 16.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +212,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index 7d036d35983..c83dcf63c6b 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +206,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index 031e8e9d24f..b0ddfaed8bf 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +75,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -135,6 +137,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +202,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +270,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +338,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index 7f1fe7d42db..e4dc14967e5 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -84,6 +85,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +152,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +217,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index eff5820d27d..a56f8f891e9 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -89,6 +90,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +147,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +210,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +288,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -346,6 +351,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -413,6 +419,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -474,6 +481,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -547,6 +555,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +623,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_lock.ambr b/tests/components/matter/snapshots/test_lock.ambr index bf34ac267d7..10ba84dd49b 100644 --- a/tests/components/matter/snapshots/test_lock.ambr +++ b/tests/components/matter/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 9d51bb92e51..dc35f6f2a69 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -177,6 +180,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -233,6 +237,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -289,6 +294,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -344,6 +350,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -388,6 +395,64 @@ 'state': '1.0', }) # --- +# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 25, + 'min': -25, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.eve_thermo_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTemperatureOffset-513-16', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Thermo Temperature offset', + 'max': 25, + 'min': -25, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.eve_thermo_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_numbers[eve_weather_sensor][number.eve_weather_altitude_above_sea_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -400,6 +465,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -457,6 +523,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +579,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -568,6 +636,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -623,6 +692,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -678,6 +748,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -734,6 +805,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -790,6 +862,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -846,6 +919,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -901,6 +975,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +1032,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1013,6 +1089,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1069,6 +1146,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1124,6 +1202,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1180,6 +1259,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1236,6 +1316,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1292,6 +1373,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1347,6 +1429,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1403,6 +1486,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1459,6 +1543,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1514,6 +1599,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 663b0cdaf51..772ee297e13 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -70,6 +71,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -138,6 +140,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -206,6 +209,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -265,6 +269,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +329,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -383,6 +389,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -442,6 +449,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +509,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -546,6 +555,62 @@ 'state': 'previous', }) # --- +# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.eve_thermo_temperature_display_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.eve_thermo_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[extended_color_light][select.mock_extended_color_light_lighting-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -559,6 +624,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -617,6 +683,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -674,6 +741,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -742,6 +810,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -821,6 +890,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -889,6 +959,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -948,6 +1019,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1005,6 +1077,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1060,6 +1133,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1120,6 +1194,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1182,6 +1257,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1241,6 +1317,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1300,6 +1377,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1359,6 +1437,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1497,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1463,22 +1543,25 @@ 'state': 'previous', }) # --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-entry] +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ + 'off', + 'normal', ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'select', 'entity_category': None, - 'entity_id': 'select.dishwasher_mode', + 'entity_id': 'select.laundrywasher_number_of_rinses', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1490,28 +1573,148 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Mode', + 'original_name': 'Number of rinses', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-MatterDishwasherMode-89-1', + 'translation_key': 'laundry_washer_number_of_rinses', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherNumberOfRinses-83-2', 'unit_of_measurement': None, }) # --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-state] +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Mode', + 'friendly_name': 'LaundryWasher Number of rinses', 'options': list([ + 'off', + 'normal', ]), }), 'context': , - 'entity_id': 'select.dishwasher_mode', + 'entity_id': 'select.laundrywasher_number_of_rinses', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_spin_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Spin speed', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_spin_speed', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-LaundryWasherControlsSpinSpeed-83-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Spin speed', + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_spin_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Off', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Cold', + 'Colors', + 'Whites', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_temperature_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature level', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_level', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlSelectedTemperatureLevel-86-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Temperature level', + 'options': list([ + 'Cold', + 'Colors', + 'Whites', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Colors', }) # --- # name: test_selects[switch_unit][select.mock_switchunit_power_on_behavior_on_startup-entry] @@ -1528,6 +1731,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1573,6 +1777,62 @@ 'state': 'previous', }) # --- +# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature display mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_mode', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Longan link HVAC Temperature display mode', + 'options': list([ + 'Celsius', + 'Fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.longan_link_hvac_temperature_display_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Celsius', + }) +# --- # name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1588,6 +1848,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1634,3 +1895,63 @@ 'state': 'Quick', }) # --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on behavior on startup', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'YNDX-00540 Power-on behavior on startup', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index f88604e7d46..9caa84bbf96 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -224,6 +228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -274,6 +279,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -325,6 +331,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -376,6 +383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +435,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -478,6 +487,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -580,6 +591,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -622,6 +634,58 @@ 'state': '20.0', }) # --- +# name: test_sensors[air_purifier][sensor.air_purifier_temperature_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_temperature_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[air_purifier][sensor.air_purifier_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Purifier Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_purifier_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_sensors[air_purifier][sensor.air_purifier_vocs-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -631,6 +695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -689,6 +754,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -746,6 +812,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -797,6 +864,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -848,6 +916,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -899,6 +968,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -950,6 +1020,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1001,6 +1072,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1052,6 +1124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1145,98 +1219,6 @@ 'state': '189.0', }) # --- -# name: test_sensors[door_lock][sensor.mock_door_lock_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock][sensor.mock_door_lock_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Battery type', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Battery type', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1246,6 +1228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,52 +1271,6 @@ 'state': '100', }) # --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_door_battery_type', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eve Door Battery type', - }), - 'context': , - 'entity_id': 'sensor.eve_door_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1343,6 +1280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1397,6 +1335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1451,6 +1390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1505,6 +1445,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1559,6 +1500,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1613,6 +1555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1670,6 +1613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1727,6 +1671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1784,6 +1729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1841,6 +1787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1883,19 +1830,22 @@ 'state': '100', }) # --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_type-entry] +# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_thermo_battery_type', + 'entity_category': None, + 'entity_id': 'sensor.eve_thermo_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1905,28 +1855,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery type', + 'original_name': 'Temperature', 'platform': 'matter', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , }) # --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_type-state] +# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eve Thermo Battery type', + 'device_class': 'temperature', + 'friendly_name': 'Eve Thermo Temperature', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.eve_thermo_battery_type', + 'entity_id': 'sensor.eve_thermo_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '', + 'state': '21.0', }) # --- # name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry] @@ -1936,6 +1889,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1985,6 +1939,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2039,6 +1994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2081,52 +2037,6 @@ 'state': '100', }) # --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_weather_battery_type', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Battery type', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_replacement_description', - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatReplacementDescription-47-19', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Eve Weather Battery type', - }), - 'context': , - 'entity_id': 'sensor.eve_weather_battery_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '', - }) -# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2136,6 +2046,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2187,6 +2098,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2241,6 +2153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2292,6 +2205,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2346,6 +2260,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2396,6 +2311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2447,6 +2363,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2503,6 +2420,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2558,6 +2476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2609,6 +2528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2660,6 +2580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2717,6 +2638,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2780,6 +2702,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2836,6 +2759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2893,6 +2817,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2941,6 +2866,358 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'LaundryWasher Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_current_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Current phase', + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-soak', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'LaundryWasher Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_operational_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalState-96-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'LaundryWasher Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundrywasher_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'LaundryWasher Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- # name: test_sensors[smoke_detector][sensor.smoke_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2950,6 +3227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2999,6 +3277,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3047,6 +3326,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3101,6 +3381,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3143,3 +3424,287 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.longan_link_hvac_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-RvcOperationalState-97-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Vacuum Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsCurrent-2820-1288', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'YNDX-00540 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.59', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementActivePower-2820-1291', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'YNDX-00540 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsVoltage-2820-1285', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'YNDX-00540 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '217.0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 9396dccd245..ebf43117846 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -187,6 +191,53 @@ 'state': 'off', }) # --- +# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_thermo_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTrvChildLock-516-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eve Thermo Child lock', + }), + 'context': , + 'entity_id': 'switch.eve_thermo_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -194,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,3 +430,51 @@ 'state': 'on', }) # --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.yndx_00540', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterPlug-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'YNDX-00540', + }), + 'context': , + 'entity_id': 'switch.yndx_00540', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index 9e6b52ed572..0703a1af4c7 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index 98634635476..99da4c2d0f6 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index eed776c132e..24243fa2038 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -14,10 +14,10 @@ import pytest from homeassistant import config_entries from homeassistant.components.matter.const import ADDON_SLUG, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index f6576689413..553358f12e3 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -502,7 +502,7 @@ async def test_issue_registry_invalid_version( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (SupervisorError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 7bcfd381d6c..bb03b296fc6 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -11,7 +11,7 @@ from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 86e1fbbf419..2a4eea1c324 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode from matter_server.common import custom_clusters +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -97,3 +99,25 @@ async def test_eve_weather_sensor_altitude( ), value=500, ) + + +@pytest.mark.parametrize("node_fixture", ["dimmable_light"]) +async def test_matter_exception_on_write_attribute( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test if a MatterError gets converted to HomeAssistantError by using a dimmable_light fixture.""" + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + matter_client.write_attribute.side_effect = MatterError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_dimmable_light_on_level", + "value": 500, + }, + blocking=True, + ) diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index ffe996fd840..2403b4b1623 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -103,3 +104,97 @@ async def test_attribute_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test ListSelect entities are discovered and working from a laundrywasher fixture.""" + state = hass.states.get("select.laundrywasher_temperature_level") + assert state + assert state.state == "Colors" + assert state.attributes["options"] == ["Cold", "Colors", "Whites"] + # Change temperature_level + set_node_attribute(matter_node, 1, 86, 4, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_temperature_level") + assert state.state == "Cold" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_temperature_level", + "option": "Whites", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.TemperatureControl.Commands.SetTemperature( + targetTemperatureLevel=2 + ), + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 86, 4, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_temperature_level") + assert state.state == "unknown" + + # SpinSpeedCurrent + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.laundrywasher_spin_speed") + assert state + assert state.state == "Off" + assert state.attributes["options"] == ["Off", "Low", "Medium", "High"] + set_node_attribute(matter_node, 1, 83, 1, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "High" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_spin_speed", + "option": "High", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + ), + value=3, + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 83, 1, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_map_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test MatterMapSelectEntity entities are discovered and working from a laundrywasher fixture.""" + # NumberOfRinses + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state + assert state.state == "off" + assert state.attributes["options"] == ["off", "normal"] + set_node_attribute(matter_node, 1, 83, 2, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state.state == "normal" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 3215ec58116..251aab73e3b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -193,6 +193,12 @@ async def test_battery_sensor_description( assert state assert state.state == "CR2032" + # case with a empty string to check if the attribute is indeed ignored + set_node_attribute(matter_node, 1, 47, 19, "") + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.smoke_sensor_battery_type") is None + @pytest.mark.parametrize("node_fixture", ["eve_thermo"]) async def test_eve_thermo_sensor( @@ -213,6 +219,18 @@ async def test_eve_thermo_sensor( assert state assert state.state == "0" + # LocalTemperature + state = hass.states.get("sensor.eve_thermo_temperature") + assert state + assert state.state == "21.0" + + set_node_attribute(matter_node, 1, 513, 0, 1800) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.eve_thermo_temperature") + assert state + assert state.state == "18.0" + @pytest.mark.parametrize("node_fixture", ["pressure_sensor"]) async def test_pressure_sensor( @@ -314,7 +332,7 @@ async def test_operational_state_sensor( matter_client: MagicMock, matter_node: MatterNode, ) -> None: - """Test dishwasher sensor.""" + """Test Operational State sensor, using a dishwasher fixture.""" # OperationalState Cluster / OperationalState attribute (1/96/4) state = hass.states.get("sensor.dishwasher_operational_state") assert state @@ -333,3 +351,51 @@ async def test_operational_state_sensor( state = hass.states.get("sensor.dishwasher_operational_state") assert state assert state.state == "extra_state" + + +@pytest.mark.parametrize("node_fixture", ["yandex_smart_socket"]) +async def test_draft_electrical_measurement_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Draft Electrical Measurement cluster sensors, using Yandex Smart Socket fixture.""" + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "70.0" + + # AcPowerDivisor + set_node_attribute(matter_node, 1, 2820, 1541, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" + + # ActivePower + set_node_attribute(matter_node, 1, 2820, 1291, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Matter List sensor.""" + # OperationalState Cluster / CurrentPhase attribute (1/96/1) + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "pre-soak" + + set_node_attribute(matter_node, 1, 96, 1, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "rinse" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index d7a6a700cde..e82848fcc3a 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -4,11 +4,14 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.errors import MatterError +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -110,3 +113,78 @@ async def test_power_switch(hass: HomeAssistant, matter_node: MatterNode) -> Non assert state assert state.state == "off" assert state.attributes["friendly_name"] == "Room AirConditioner Power" + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo"]) +async def test_numeric_switch( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test numeric switch entity is discovered and working using an Eve Thermo fixture .""" + state = hass.states.get("switch.eve_thermo_child_lock") + assert state + assert state.state == "off" + # name should be derived from description attribute + assert state.attributes["friendly_name"] == "Eve Thermo Child lock" + # test attribute changes + set_node_attribute(matter_node, 1, 516, 1, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_thermo_child_lock") + assert state.state == "on" + set_node_attribute(matter_node, 1, 516, 1, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("switch.eve_thermo_child_lock") + assert state.state == "off" + # test switch service + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.eve_thermo_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + value=1, + ) + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.eve_thermo_child_lock"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[1] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout, + ), + value=0, + ) + + +@pytest.mark.parametrize("node_fixture", ["on_off_plugin_unit"]) +async def test_matter_exception_on_command( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test if a MatterError gets converted to HomeAssistantError by using a switch fixture.""" + state = hass.states.get("switch.mock_onoffpluginunit") + assert state + matter_client.send_device_command.side_effect = MatterError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", + "turn_on", + { + "entity_id": "switch.mock_onoffpluginunit", + }, + blocking=True, + ) diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 5d15f01389b..b024c214888 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -1,7 +1,11 @@ """Tests for the Mazda Connected Services integration.""" from homeassistant.components.mazda import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_mazda_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_mazda_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/mcp/__init__.py b/tests/components/mcp/__init__.py new file mode 100644 index 00000000000..e8e8635ab36 --- /dev/null +++ b/tests/components/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the Model Context Protocol integration.""" diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py new file mode 100644 index 00000000000..d86603a12ed --- /dev/null +++ b/tests/components/mcp/conftest.py @@ -0,0 +1,45 @@ +"""Common fixtures for the Model Context Protocol tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mcp.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_API_NAME = "Memory Server" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mcp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mcp_client() -> Generator[AsyncMock]: + """Fixture to mock the MCP client.""" + with ( + patch("homeassistant.components.mcp.coordinator.sse_client"), + patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + ): + yield mock_session.return_value.__aenter__ + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Fixture to load the integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/sse"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py new file mode 100644 index 00000000000..29733e653a6 --- /dev/null +++ b/tests/components/mcp/test_config_flow.py @@ -0,0 +1,234 @@ +"""Test the Model Context Protocol config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from homeassistant import config_entries +from homeassistant.components.mcp.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_API_NAME + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test the complete configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_mcp_client_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle different client library errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_mcp_client.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Reset the error and make sure the config flow can resume successfully. + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), + "invalid_auth", + ), + ], +) +async def test_form_mcp_client_error_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle different client library errors that end with an abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_mcp_client.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + "user_input", + [ + ({CONF_URL: "not a url"}), + ({CONF_URL: "rtsp://1.1.1.1"}), + ], +) +async def test_input_form_validation_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + user_input: dict[str, Any], +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_URL: "invalid_url"} + + # Reset the error and make sure the config flow can resume successfully. + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test that the same url cannot be configured twice.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/sse"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_server_missing_capbilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test we handle different client library errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py new file mode 100644 index 00000000000..460df2c5785 --- /dev/null +++ b/tests/components/mcp/test_init.py @@ -0,0 +1,225 @@ +"""Tests for the Model Context Protocol component.""" + +import re +from unittest.mock import Mock, patch + +import httpx +from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool +import pytest +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm + +from .conftest import TEST_API_NAME + +from tests.common import MockConfigEntry + +SEARCH_MEMORY_TOOL = Tool( + name="search_memory", + description="Search memory for relevant context based on a query.", + inputSchema={ + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "A free text query to search context for.", + } + }, + }, +) +SAVE_MEMORY_TOOL = Tool( + name="save_memory", + description="Save a memory context.", + inputSchema={ + "type": "object", + "required": ["context"], + "properties": { + "context": { + "type": "object", + "description": "The context to save.", + "properties": { + "fact": { + "type": "string", + "description": "The key for the context.", + }, + }, + }, + }, + }, +) + + +def create_llm_context() -> llm.LLMContext: + """Create a test LLM context.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_init( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration is initialized and can be unloaded cleanly.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_mcp_server_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "", request=None, response=httpx.Response(500) + ) + + with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_list_tools_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration fails to load if the first data fetch returns an error.""" + mock_mcp_client.return_value.list_tools.side_effect = httpx.HTTPStatusError( + "", request=None, response=httpx.Response(500) + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_llm_get_api_tools( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test MCP tools are returned as LLM API tools.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL, SAVE_MEMORY_TOOL], + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 2 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + assert tool.description == "Search memory for relevant context based on a query." + with pytest.raises( + vol.Invalid, match=re.escape("required key not provided @ data['query']") + ): + tool.parameters({}) + assert tool.parameters({"query": "frogs"}) == {"query": "frogs"} + + tool = api_instance.tools[1] + assert tool.name == "save_memory" + assert tool.description == "Save a memory context." + with pytest.raises( + vol.Invalid, match=re.escape("required key not provided @ data['context']") + ): + tool.parameters({}) + assert tool.parameters({"context": {"fact": "User was born in February"}}) == { + "context": {"fact": "User was born in February"} + } + + +async def test_call_tool( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test calling an MCP Tool through the LLM API.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 1 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + + mock_mcp_client.return_value.call_tool.return_value = CallToolResult( + content=[TextContent(type="text", text="User was born in February")] + ) + result = await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + assert result == { + "content": [{"text": "User was born in February", "type": "text"}] + } + + +async def test_call_tool_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test handling an MCP Tool call failure.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 1 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Server error", request=None, response=httpx.Response(500) + ) + with pytest.raises( + HomeAssistantError, match="Error when calling tool: Server error" + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + +async def test_convert_tool_schema_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test a failure converting an MCP tool schema to a Home Assistant schema.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + with patch( + "homeassistant.components.mcp.coordinator.convert_to_voluptuous", + side_effect=ValueError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 149073f3645..5ec67fb6ce3 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp_server.const import DOMAIN +from homeassistant.components.mcp_server.const import DOMAIN, LLM_API from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant -from homeassistant.helpers import llm from tests.common import MockConfigEntry @@ -28,7 +27,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: LLM_API, }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index a71bf42acc8..905bfaa11d7 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -20,7 +20,11 @@ from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, setup_test_component_platform @@ -45,6 +49,11 @@ INITIALIZE_MESSAGE = { } EVENT_PREFIX = "event: " DATA_PREFIX = "data: " +EXPECTED_PROMPT_SUFFIX = """ +- names: Kitchen Light + domain: light + areas: Kitchen +""" @pytest.fixture @@ -59,11 +68,13 @@ async def mock_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, setup_integration: None, ) -> None: """Fixture to expose entities to the conversation agent.""" - entity = MockLight("kitchen", STATE_OFF) + entity = MockLight("Kitchen Light", STATE_OFF) entity.entity_id = TEST_ENTITY + entity.unique_id = "test-light-unique-id" setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) assert await async_setup_component( @@ -71,6 +82,9 @@ async def mock_entities( LIGHT_DOMAIN, {LIGHT_DOMAIN: [{"platform": "test"}]}, ) + await hass.async_block_till_done() + kitchen = area_registry.async_get_or_create("Kitchen") + entity_registry.async_update_entity(TEST_ENTITY, area_id=kitchen.id) async_expose_entity(hass, CONVERSATION_DOMAIN, TEST_ENTITY, True) @@ -320,7 +334,7 @@ async def test_mcp_tool_call( async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: result = await session.call_tool( name="HassTurnOn", - arguments={"name": "kitchen"}, + arguments={"name": "kitchen light"}, ) assert not result.isError @@ -370,8 +384,11 @@ async def test_prompt_list( assert len(result.prompts) == 1 prompt = result.prompts[0] - assert prompt.name == "Assist" - assert prompt.description == "Default prompt for the Home Assistant LLM API Assist" + assert prompt.name == "Stateless Assist" + assert ( + prompt.description + == "Default prompt for the Home Assistant LLM API Stateless Assist" + ) async def test_prompt_get( @@ -383,13 +400,17 @@ async def test_prompt_get( """Test the get prompt endpoint.""" async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: - result = await session.get_prompt(name="Assist") + result = await session.get_prompt(name="Stateless Assist") - assert result.description == "Default prompt for the Home Assistant LLM API Assist" + assert ( + result.description + == "Default prompt for the Home Assistant LLM API Stateless Assist" + ) assert len(result.messages) == 1 assert result.messages[0].role == "assistant" assert result.messages[0].content.type == "text" assert "When controlling Home Assistant" in result.messages[0].content.text + assert result.messages[0].content.text.endswith(EXPECTED_PROMPT_SUFFIX) async def test_get_unknwon_prompt( diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index e5a0a697157..7587a7a55b7 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -170,6 +170,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -222,6 +223,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -274,6 +276,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,6 +329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index 98ca52dd15e..aada173ffc3 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr index e645cf4c45f..19219c01c1c 100644 --- a/tests/components/mealie/snapshots/test_sensor.ambr +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mealie/snapshots/test_todo.ambr b/tests/components/mealie/snapshots/test_todo.ambr index 4c58a839f57..88c677de581 100644 --- a/tests/components/mealie/snapshots/test_todo.ambr +++ b/tests/components/mealie/snapshots/test_todo.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 750d2861f21..680603c097d 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.media_player as mp +from homeassistant.components import media_player as mp from homeassistant.const import ( STATE_IDLE, STATE_OFF, diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 4bb27b73f24..ae3a84e66a0 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index a45fa5b6668..1878d7372f6 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,18 +10,25 @@ import voluptuous as vol from homeassistant.components import media_player from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, BrowseMedia, MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) +from homeassistant.components.media_player.const import SERVICE_BROWSE_MEDIA from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import help_test_all, import_and_test_deprecated_constant_enum +from tests.common import ( + MockEntityPlatform, + help_test_all, + import_and_test_deprecated_constant_enum, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -116,19 +123,27 @@ def test_deprecated_constants_const( "grouping", ], ) -def test_support_properties(property_suffix: str) -> None: +def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: """Test support_*** properties explicitly.""" all_features = media_player.MediaPlayerEntityFeature(653887) feature = media_player.MediaPlayerEntityFeature[property_suffix.upper()] entity1 = MediaPlayerEntity() + entity1.hass = hass + entity1.platform = MockEntityPlatform(hass) entity1._attr_supported_features = media_player.MediaPlayerEntityFeature(0) entity2 = MediaPlayerEntity() + entity2.hass = hass + entity2.platform = MockEntityPlatform(hass) entity2._attr_supported_features = all_features entity3 = MediaPlayerEntity() + entity3.hass = hass + entity3.platform = MockEntityPlatform(hass) entity3._attr_supported_features = feature entity4 = MediaPlayerEntity() + entity4.hass = hass + entity4.platform = MockEntityPlatform(hass) entity4._attr_supported_features = all_features - feature assert getattr(entity1, f"support_{property_suffix}") is False @@ -152,8 +167,7 @@ async def test_get_image_http( client = await hass_client_no_auth() with patch( - "homeassistant.components.media_player.MediaPlayerEntity." - "async_get_media_image", + "homeassistant.components.media_player.MediaPlayerEntity.async_get_media_image", return_value=(b"image", "image/jpeg"), ): resp = await client.get(state.attributes["entity_picture"]) @@ -269,7 +283,7 @@ async def test_media_browse( client = await hass_ws_client(hass) with patch( - "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", return_value=BrowseMedia( media_class=MediaClass.DIRECTORY, media_content_id="mock-id", @@ -309,7 +323,7 @@ async def test_media_browse( assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") with patch( - "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", return_value={"bla": "yo"}, ): await client.send_json( @@ -328,6 +342,75 @@ async def test_media_browse( assert msg["result"] == {"bla": "yo"} +async def test_media_browse_service(hass: HomeAssistant) -> None: + """Test browsing media using service call.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", + return_value=BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id="album1 content id", + media_content_type="album", + title="Album 1", + can_play=True, + can_expand=True, + ), + BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id="album2 content id", + media_content_type="album", + title="Album 2", + can_play=True, + can_expand=True, + ), + ], + ), + ) as mock_browse_media: + result = await hass.services.async_call( + "media_player", + SERVICE_BROWSE_MEDIA, + { + ATTR_ENTITY_ID: "media_player.browse", + ATTR_MEDIA_CONTENT_TYPE: "album", + ATTR_MEDIA_CONTENT_ID: "title=Album*", + }, + blocking=True, + return_response=True, + ) + + mock_browse_media.assert_called_with( + media_content_type="album", media_content_id="title=Album*" + ) + browse_res: BrowseMedia = result["media_player.browse"] + assert browse_res.title == "Mock Title" + assert browse_res.media_class == "directory" + assert browse_res.media_content_type == "mock-type" + assert browse_res.media_content_id == "mock-id" + assert browse_res.can_play is False + assert browse_res.can_expand is True + assert len(browse_res.children) == 2 + assert browse_res.children[0].title == "Album 1" + assert browse_res.children[0].media_class == "album" + assert browse_res.children[0].media_content_id == "album1 content id" + assert browse_res.children[0].media_content_type == "album" + assert browse_res.children[1].title == "Album 2" + assert browse_res.children[1].media_class == "album" + assert browse_res.children[1].media_content_id == "album2 content id" + assert browse_res.children[1].media_content_type == "album" + + async def test_group_members_available_when_off(hass: HomeAssistant) -> None: """Test that group_members are still available when media_player is off.""" await async_setup_component( @@ -449,7 +532,9 @@ async def test_get_async_get_browse_image_quoting( mock_browse_image.assert_called_with("album", media_content_id, None) -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockMediaPlayerEntity(MediaPlayerEntity): @@ -459,6 +544,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockMediaPlayerEntity() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert entity.supported_features_compat is MediaPlayerEntityFeature(1) assert "MockMediaPlayerEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr index e6a432de07e..671f5afcc52 100644 --- a/tests/components/melcloud/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'melcloud', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index ceb14faf8fb..b305d629a91 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -10,7 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index a2ba23d9e61..23902a4b780 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/melnor/test_time.py b/tests/components/melnor/test_time.py index 50b51d31ff8..f8a3adcf3d0 100644 --- a/tests/components/melnor/test_time.py +++ b/tests/components/melnor/test_time.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import time, timedelta from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index 123fc00e42a..eb28ec0a838 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -2,13 +2,48 @@ from unittest.mock import patch +from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain import pytest +from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient") + with patch("homeassistant.components.meteo_france.MeteoFranceClient") as mock_data: + mock_data = mock_data.return_value + mock_data.get_forecast.return_value = Forecast( + load_json_object_fixture("raw_forecast.json", DOMAIN) + ) + mock_data.get_rain.return_value = Rain( + load_json_object_fixture("raw_rain.json", DOMAIN) + ) + mock_data.get_warning_current_phenomenoms.return_value = CurrentPhenomenons( + load_json_object_fixture("raw_warning_current_phenomenoms.json", DOMAIN) + ) + yield mock_data - with patch_client: - yield + +@pytest.fixture(name="config_entry") +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create and register mock config entry.""" + entry_data = { + CONF_CITY: "La Clusaz", + CONF_LATITUDE: 45.90417, + CONF_LONGITUDE: 6.42306, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + unique_id=f"{entry_data[CONF_LATITUDE], entry_data[CONF_LONGITUDE]}", + title=entry_data[CONF_CITY], + data=entry_data, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/meteo_france/fixtures/raw_forecast.json b/tests/components/meteo_france/fixtures/raw_forecast.json new file mode 100644 index 00000000000..3c0552136d2 --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_forecast.json @@ -0,0 +1,53 @@ +{ + "updated_on": 1737995400, + "position": { + "country": "FR - France", + "dept": "74", + "insee": "74080", + "lat": 45.90417, + "lon": 6.42306, + "name": "La Clusaz", + "rain_product_available": 1, + "timezone": "Europe/Paris" + }, + "daily_forecast": [ + { + "T": { "max": 10.4, "min": 6.9, "sea": null }, + "dt": 1737936000, + "humidity": { "max": 90, "min": 65 }, + "precipitation": { "24h": 1.3 }, + "sun": { "rise": 1737963392, "set": 1737996163 }, + "uv": 1, + "weather12H": { "desc": "Eclaircies", "icon": "p2j" } + } + ], + "forecast": [ + { + "T": { "value": 9.1, "windchill": 5.4 }, + "clouds": 70, + "dt": 1737990000, + "humidity": 75, + "iso0": 1250, + "rain": { "1h": 0 }, + "rain snow limit": "Non pertinent", + "sea_level": 988.7, + "snow": { "1h": 0 }, + "uv": 1, + "weather": { "desc": "Eclaircies", "icon": "p2j" }, + "wind": { + "direction": 200, + "gust": 18, + "icon": "SSO", + "speed": 8 + } + } + ], + "probability_forecast": [ + { + "dt": 1737990000, + "freezing": 0, + "rain": { "3h": null, "6h": null }, + "snow": { "3h": null, "6h": null } + } + ] +} diff --git a/tests/components/meteo_france/fixtures/raw_rain.json b/tests/components/meteo_france/fixtures/raw_rain.json new file mode 100644 index 00000000000..a9f17b8a98e --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_rain.json @@ -0,0 +1,24 @@ +{ + "position": { + "lat": 48.807166, + "lon": 2.239895, + "alti": 76, + "name": "Meudon", + "country": "FR - France", + "dept": "92", + "timezone": "Europe/Paris" + }, + "updated_on": 1589995200, + "quality": 0, + "forecast": [ + { "dt": 1589996100, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589996400, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589996700, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589997000, "rain": 2, "desc": "Pluie faible" }, + { "dt": 1589997300, "rain": 3, "desc": "Pluie modérée" }, + { "dt": 1589997600, "rain": 2, "desc": "Pluie faible" }, + { "dt": 1589998200, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589998800, "rain": 1, "desc": "Temps sec" }, + { "dt": 1589999400, "rain": 1, "desc": "Temps sec" } + ] +} diff --git a/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json new file mode 100644 index 00000000000..8d84e512fb6 --- /dev/null +++ b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json @@ -0,0 +1,13 @@ +{ + "update_time": 1591279200, + "end_validity_time": 1591365600, + "domain_id": "32", + "phenomenons_max_colors": [ + { "phenomenon_id": "6", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "4", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "5", "phenomenon_max_color_id": 3 }, + { "phenomenon_id": "2", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "1", "phenomenon_max_color_id": 1 }, + { "phenomenon_id": "3", "phenomenon_max_color_id": 2 } + ] +} diff --git a/tests/components/meteo_france/snapshots/test_sensor.ambr b/tests/components/meteo_france/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..35b6a9d19f7 --- /dev/null +++ b/tests/components/meteo_france/snapshots/test_sensor.ambr @@ -0,0 +1,779 @@ +# serializer version: 1 +# name: test_sensor[sensor.32_weather_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.32_weather_alert', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy-alert', + 'original_name': '32 Weather alert', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '32 Weather alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.32_weather_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'Canicule': 'Vert', + 'Inondation': 'Vert', + 'Neige-verglas': 'Orange', + 'Orages': 'Jaune', + 'Pluie-inondation': 'Vert', + 'Vent violent': 'Vert', + 'attribution': 'Data provided by Météo-France', + 'friendly_name': '32 Weather alert', + 'icon': 'mdi:weather-cloudy-alert', + }), + 'context': , + 'entity_id': 'sensor.32_weather_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Orange', + }) +# --- +# name: test_sensor[sensor.la_clusaz_cloud_cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_cloud_cover', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'La Clusaz Cloud cover', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_cloud_cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Cloud cover', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_cloud_cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_original_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_daily_original_condition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz Daily original condition', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_daily_original_condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_original_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Daily original condition', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_daily_original_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Eclaircies', + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_precipitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_daily_precipitation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Daily precipitation', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_precipitation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_daily_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'precipitation', + 'friendly_name': 'La Clusaz Daily precipitation', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_daily_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.3', + }) +# --- +# name: test_sensor[sensor.la_clusaz_freeze_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_freeze_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:snowflake', + 'original_name': 'La Clusaz Freeze chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_freeze_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_freeze_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Freeze chance', + 'icon': 'mdi:snowflake', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_freeze_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.la_clusaz_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Humidity', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'humidity', + 'friendly_name': 'La Clusaz Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensor[sensor.la_clusaz_original_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_original_condition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz Original condition', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_original_condition', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.la_clusaz_original_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Original condition', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_original_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Eclaircies', + }) +# --- +# name: test_sensor[sensor.la_clusaz_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Pressure', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'pressure', + 'friendly_name': 'La Clusaz Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '988.7', + }) +# --- +# name: test_sensor[sensor.la_clusaz_rain_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_rain_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-rainy', + 'original_name': 'La Clusaz Rain chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_rain_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_rain_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Rain chance', + 'icon': 'mdi:weather-rainy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_rain_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.la_clusaz_snow_chance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_snow_chance', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-snowy', + 'original_name': 'La Clusaz Snow chance', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_snow_chance', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.la_clusaz_snow_chance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz Snow chance', + 'icon': 'mdi:weather-snowy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_snow_chance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.la_clusaz_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Temperature', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'temperature', + 'friendly_name': 'La Clusaz Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.1', + }) +# --- +# name: test_sensor[sensor.la_clusaz_uv-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_uv', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sunglasses', + 'original_name': 'La Clusaz UV', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_uv', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.la_clusaz_uv-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz UV', + 'icon': 'mdi:sunglasses', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_uv', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_wind_gust', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:weather-windy-variant', + 'original_name': 'La Clusaz Wind gust', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'wind_speed', + 'friendly_name': 'La Clusaz Wind gust', + 'icon': 'mdi:weather-windy-variant', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.la_clusaz_wind_speed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'La Clusaz Wind speed', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '45.90417,6.42306_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.la_clusaz_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'device_class': 'wind_speed', + 'friendly_name': 'La Clusaz Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.la_clusaz_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor[sensor.meudon_next_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meudon_next_rain', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meudon Next rain', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '48.807166,2.239895_next_rain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.meudon_next_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + '1_hour_forecast': dict({ + '0 min': 'Temps sec', + '10 min': 'Temps sec', + '15 min': 'Pluie faible', + '20 min': 'Pluie modérée', + '25 min': 'Pluie faible', + '35 min': 'Temps sec', + '45 min': 'Temps sec', + '5 min': 'Temps sec', + '55 min': 'Temps sec', + }), + 'attribution': 'Data provided by Météo-France', + 'device_class': 'timestamp', + 'forecast_time_ref': '2020-05-20T17:35:00+00:00', + 'friendly_name': 'Meudon Next rain', + }), + 'context': , + 'entity_id': 'sensor.meudon_next_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-05-20T17:50:00+00:00', + }) +# --- diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr new file mode 100644 index 00000000000..7c64ee86671 --- /dev/null +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_weather[weather.la_clusaz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.la_clusaz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'La Clusaz', + 'platform': 'meteo_france', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '45.90417,6.42306', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather[weather.la_clusaz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Météo-France', + 'friendly_name': 'La Clusaz', + 'humidity': 75, + 'precipitation_unit': , + 'pressure': 988.7, + 'pressure_unit': , + 'supported_features': , + 'temperature': 9.1, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 200, + 'wind_speed': 28.8, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.la_clusaz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'partlycloudy', + }) +# --- diff --git a/tests/components/meteo_france/test_sensor.py b/tests/components/meteo_france/test_sensor.py new file mode 100644 index 00000000000..be77de0008b --- /dev/null +++ b/tests/components/meteo_france/test_sensor.py @@ -0,0 +1,32 @@ +"""Test Météo France weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_france.PLATFORMS", [Platform.SENSOR]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entity.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/meteo_france/test_weather.py b/tests/components/meteo_france/test_weather.py new file mode 100644 index 00000000000..cd55ac31b27 --- /dev/null +++ b/tests/components/meteo_france/test_weather.py @@ -0,0 +1,31 @@ +"""Test Météo France weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_france.PLATFORMS", [Platform.WEATHER]): + yield + + +async def test_weather( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the weather entity.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 37512ca78f8..8c21fa9cb36 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -7,8 +7,8 @@ from mficlient.client import FailedToLogin import pytest import requests -import homeassistant.components.mfi.sensor as mfi -import homeassistant.components.sensor as sensor_component +from homeassistant.components import sensor as sensor_component +from homeassistant.components.mfi import sensor as mfi from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py index 03b5d5f2c0a..fb586073a3d 100644 --- a/tests/components/mfi/test_switch.py +++ b/tests/components/mfi/test_switch.py @@ -4,8 +4,8 @@ from unittest import mock import pytest -import homeassistant.components.mfi.switch as mfi -import homeassistant.components.switch as switch_component +from homeassistant.components import switch as switch_component +from homeassistant.components.mfi import switch as mfi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index e10ec589113..38f1318a683 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -366,7 +366,7 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index c875697bf2f..a7a70043d94 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py index d34db5114cc..67b8bd17b3a 100644 --- a/tests/components/minecraft_server/conftest.py +++ b/tests/components/minecraft_server/conftest.py @@ -3,8 +3,8 @@ import pytest from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.components.minecraft_server.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .const import TEST_ADDRESS, TEST_CONFIG_ENTRY_ID @@ -18,8 +18,8 @@ def java_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.JAVA_EDITION, }, @@ -34,8 +34,8 @@ def bedrock_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.BEDROCK_EDITION, }, diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index 2e4bf49089c..c93a87d70d8 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -3,10 +3,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -17,10 +17,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -31,10 +31,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -45,10 +45,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr index 72d79795c6a..b722f4122f3 100644 --- a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr +++ b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Bedrock Edition', }), 'config_entry_options': dict({ @@ -36,7 +35,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Java Edition', }), 'config_entry_options': dict({ diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index 47d638adf79..d2b044c06f5 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -2,11 +2,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -16,11 +16,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -30,11 +30,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -44,10 +44,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -57,10 +57,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -70,10 +70,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -83,10 +83,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -96,10 +96,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -109,10 +109,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -122,11 +122,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -136,7 +136,7 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -145,7 +145,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -155,11 +155,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -169,10 +169,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -182,10 +182,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -195,10 +195,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -208,11 +208,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -222,11 +222,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -236,11 +236,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -250,10 +250,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -263,10 +263,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -276,10 +276,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -289,10 +289,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -302,10 +302,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -315,10 +315,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -328,11 +328,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -342,7 +342,7 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -351,7 +351,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -361,11 +361,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -375,10 +375,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -388,10 +388,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -401,10 +401,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 6321c91d74a..77537a5e8e4 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -64,7 +64,9 @@ async def test_binary_sensor( ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -113,7 +115,9 @@ async def test_binary_sensor_update( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -167,5 +171,6 @@ async def test_binary_sensor_update_failure( async_fire_time_changed(hass) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.minecraft_server_status").state == STATE_OFF + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status").state + == STATE_OFF ) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 41817986bcf..c57b74c6a65 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import patch from mcstatus import BedrockServer, JavaServer from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -22,13 +22,12 @@ from .const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } -async def test_show_config_form(hass: HomeAssistant) -> None: - """Test if initial configuration form is shown.""" +async def test_full_flow_java(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -36,96 +35,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - -async def test_service_already_configured( - hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry -) -> None: - """Test config flow abort if service is already configured.""" - bedrock_mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - return_value=TEST_BEDROCK_STATUS_RESPONSE, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_address_validation_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - side_effect=ValueError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Java Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_bedrock_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Bedrock Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection to a Java Edition server.""" with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -146,13 +55,19 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION -async def test_bedrock_connection(hass: HomeAssistant) -> None: +async def test_full_flow_bedrock(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection to a Bedrock Edition server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -169,13 +84,16 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION -async def test_recovery(hass: HomeAssistant) -> None: - """Test config flow recovery (successful connection after a failed connection).""" +async def test_service_already_configured_java( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Java Edition server is already configured.""" + java_mock_config_entry.add_to_hass(hass) + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -183,8 +101,99 @@ async def test_recovery(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_service_already_configured_bedrock( + hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Bedrock Edition server is already configured.""" + bedrock_mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_recovery_java(hass: HomeAssistant) -> None: + """Test config flow recovery with a Java Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + side_effect=OSError, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_recovery_bedrock(hass: HomeAssistant) -> None: + """Test config flow recovery with a Bedrock Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + side_effect=OSError, + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -207,6 +216,5 @@ async def test_recovery(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == USER_INPUT[CONF_ADDRESS] - assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 6f7a49a190c..c00c5ec80cd 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -6,7 +6,7 @@ from mcstatus import JavaServer import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT @@ -23,6 +23,8 @@ from .const import ( from tests.common import MockConfigEntry +DEFAULT_NAME = "Minecraft Server" + TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_PORT}" SENSOR_KEYS = [ diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index ff62f8ddf36..a4cea239f7a 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -22,35 +22,35 @@ from .const import ( from tests.common import async_fire_time_changed JAVA_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", ] JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", ] BEDROCK_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_map_name", - "sensor.minecraft_server_game_mode", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_map_name", + "sensor.mc_dummyserver_com_25566_game_mode", + "sensor.mc_dummyserver_com_25566_edition", ] BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_edition", ] diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index dda5f369ad5..b071caebd16 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1081,6 +1081,7 @@ async def test_webhook_handle_conversation_process( }, }, "conversation_id": None, + "continue_conversation": False, } diff --git a/tests/components/mochad/test_switch.py b/tests/components/mochad/test_switch.py index 9fea3b5c14c..d7875246fac 100644 --- a/tests/components/mochad/test_switch.py +++ b/tests/components/mochad/test_switch.py @@ -9,6 +9,8 @@ from homeassistant.components.mochad import switch as mochad from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockEntityPlatform + @pytest.fixture(autouse=True) def pymochad_mock(): @@ -25,7 +27,9 @@ def switch_mock(hass: HomeAssistant) -> mochad.MochadSwitch: """Mock switch.""" controller_mock = mock.MagicMock() dev_dict = {"address": "a1", "name": "fake_switch"} - return mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity = mochad.MochadSwitch(hass, controller_mock, dev_dict) + entity.platform = MockEntityPlatform(hass) + return entity async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None: diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index cdea046ceea..a35cc95605d 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_restore_cache @@ -42,6 +42,7 @@ class ReadResult: self.registers = register_words self.bits = register_words self.value = register_words + self.count = len(register_words) if register_words is not None else 0 def isError(self): """Set error state.""" diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 24293377174..e1c0e08a113 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -422,9 +422,9 @@ async def test_virtual_binary_sensor( assert hass.states.get(ENTITY_ID).state == expected for i, slave in enumerate(slaves): - entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}".replace(" ", "_") + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i + 1}".replace(" ", "_") assert hass.states.get(entity_id).state == slave - unique_id = f"{SLAVE_UNIQUE_ID}_{i+1}" + unique_id = f"{SLAVE_UNIQUE_ID}_{i + 1}" entry = entity_registry.async_get(entity_id) assert entry.unique_id == unique_id diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 1520e4478c6..3c30efe9dce 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -58,6 +58,7 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -366,6 +367,29 @@ async def test_config_hvac_onoff_register(hass: HomeAssistant, mock_modbus) -> N assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 11, + } + ], + }, + ], +) +async def test_config_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off coil.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -394,7 +418,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, 0xAA, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xAA, slave=10) await hass.services.async_call( CLIMATE_DOMAIN, @@ -404,7 +428,46 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, 0xFF, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_COIL: 11, + } + ], + }, + ], +) +async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off coil values.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_coil.assert_called_with(11, value=1, slave=10) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_coil.assert_called_with(11, value=0, slave=10) @pytest.mark.parametrize( @@ -562,6 +625,126 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words", "coil_value"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: [130, 131, 132, 133, 134, 135, 136], + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_DRY: 2, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.COOL, + [0x00], + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 119, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_DRY: 2, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.HEAT, + [0x01], + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 2, + CONF_HVAC_MODE_DRY: 3, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + "unavailable", + [0x00], + None, + ), + ], +) +async def test_hvac_onoff_coil_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words, coil_value +) -> None: + """Test climate update based on On/Off coil values.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5dd3f6e9033..7b76dbc3528 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -107,7 +107,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( TEST_ENTITY_NAME, @@ -846,6 +846,13 @@ async def test_pb_service_write( CALL_TYPE_WRITE_REGISTERS: mock_modbus_with_pymodbus.write_registers, } + value_arg_name = { + CALL_TYPE_WRITE_COIL: "value", + CALL_TYPE_WRITE_COILS: "values", + CALL_TYPE_WRITE_REGISTER: "value", + CALL_TYPE_WRITE_REGISTERS: "values", + } + data = { ATTR_HUB: TEST_MODBUS_NAME, do_slave: 17, @@ -858,10 +865,12 @@ async def test_pb_service_write( func_name[do_write[FUNC]].return_value = do_return[VALUE] await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True) assert func_name[do_write[FUNC]].called - assert func_name[do_write[FUNC]].call_args[0] == ( - data[ATTR_ADDRESS], - data[do_write[DATA]], - ) + assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) + assert func_name[do_write[FUNC]].call_args.kwargs == { + "slave": 17, + value_arg_name[do_write[FUNC]]: data[do_write[DATA]], + } + if do_return[DATA]: assert any(message.startswith("Pymodbus:") for message in caplog.messages) @@ -1265,3 +1274,56 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False + + +@pytest.mark.parametrize( + ("do_config", "expected_slave_value"), + [ + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + }, + ], + }, + 1, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_SLAVE: 0, + }, + ], + }, + 0, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 6, + }, + ], + }, + 6, + ), + ], +) +async def test_check_default_slave( + hass: HomeAssistant, + mock_modbus, + do_config, + mock_do_cycle, + expected_slave_value: int, +) -> None: + """Test default slave.""" + assert mock_modbus.read_holding_registers.mock_calls + first_call = mock_modbus.read_holding_registers.mock_calls[0] + assert first_call.kwargs["slave"] == expected_slave_value diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4e0ad0841ea..fc994c70d49 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -12,6 +12,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_STATE_OFF, @@ -41,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_NAME, ReadResult @@ -50,6 +51,7 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" ENTITY_ID3 = f"{ENTITY_ID}_3" +ENTITY_ID4 = f"{ENTITY_ID}_4" @pytest.mark.parametrize( @@ -330,6 +332,13 @@ async def test_restore_state_switch( CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {CONF_STATE_ON: [1, 3]}, }, + { + CONF_NAME: f"{TEST_ENTITY_NAME} 4", + CONF_ADDRESS: 19, + CONF_WRITE_TYPE: CALL_TYPE_X_REGISTER_HOLDINGS, + CONF_SCAN_INTERVAL: 0, + CONF_VERIFY: {CONF_STATE_ON: [1, 3]}, + }, ], }, ], @@ -381,6 +390,20 @@ async def test_switch_service_turn( await hass.async_block_till_done() assert hass.states.get(ENTITY_ID3).state == STATE_OFF + mock_modbus.read_holding_registers.return_value = ReadResult([0x03]) + assert hass.states.get(ENTITY_ID4).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID4} + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID4).state == STATE_ON + mock_modbus.read_holding_registers.return_value = ReadResult([0x00]) + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, service_data={ATTR_ENTITY_ID: ENTITY_ID4} + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID4).state == STATE_OFF + mock_modbus.write_register.side_effect = ModbusException("fail write_") await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID2} diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py index 2ae4d6659e7..280deadc733 100644 --- a/tests/components/modem_callerid/test_config_flow.py +++ b/tests/components/modem_callerid/test_config_flow.py @@ -10,10 +10,11 @@ from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_DEVICE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo from . import com_port, patch_config_flow_modem -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=phone_modem.DEFAULT_PORT, pid="1340", vid="0572", diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index f8897a4a47f..1b4090ca5a4 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'AA:BB:CC:DD:EE:FF', 'version': 1, diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 5b10d4d729e..4ec5e92cd72 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock, patch import aiohttp from aiomodernforms import ModernFormsConnectionError -from homeassistant.components import zeroconf from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import init_integration @@ -66,7 +66,7 @@ async def test_full_zeroconf_flow_implementation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -138,7 +138,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -170,7 +170,7 @@ async def test_zeroconf_confirm_connection_error( CONF_HOST: "example.com", CONF_NAME: "test", }, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", @@ -220,7 +220,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr index dc6680ff99a..461cb33d776 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr index 7dfb9edb2e8..27244d781df 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr index c1a63271a33..0708137e1cf 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ 'target_temp_step': 0.2, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr index 3fee26a6ed5..4b1c702591d 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr index cf7e0cb7b2f..b70302188ed 100644 --- a/tests/components/monarch_money/snapshots/test_sensor.ambr +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -261,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,6 +420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -513,6 +523,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -564,6 +575,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +678,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -715,6 +729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +781,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -816,6 +832,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,6 +884,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -915,6 +933,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -965,6 +984,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1038,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1069,6 +1090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr index 9be5943d35c..8d3f83ed4f1 100644 --- a/tests/components/monzo/snapshots/test_sensor.ambr +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +218,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mopeka/test_config_flow.py b/tests/components/mopeka/test_config_flow.py index 7a341052f22..d2887451629 100644 --- a/tests/components/mopeka/test_config_flow.py +++ b/tests/components/mopeka/test_config_flow.py @@ -81,6 +81,39 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=PRO_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.mopeka.config_flow.async_discovered_service_info", + return_value=[PRO_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.mopeka.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pro Plus EEFF" + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 77171b06ad6..821e4fa0278 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.motion_blinds import const from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -346,7 +346,7 @@ async def test_config_flow_invalid_interface(hass: HomeAssistant) -> None: async def test_dhcp_flow(hass: HomeAssistant) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -380,7 +380,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if not Motionblinds.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, @@ -400,7 +400,7 @@ async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: async def test_dhcp_flow_abort_invalid_response(hass: HomeAssistant) -> None: """Test that DHCP discovery aborts if device responded with invalid data.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="MOTION_abcdef", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr index 5b4b169c0fe..d042dc02ac3 100644 --- a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -28,6 +28,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index 842d862a222..c403f9f072f 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry TEST_CONFIG_ENTRY_ID = "74565ad414754616000674c87bdc876c" -TEST_URL = f"http://test:{DEFAULT_PORT+1}" +TEST_URL = f"http://test:{DEFAULT_PORT + 1}" TEST_CAMERA_ID = 100 TEST_CAMERA_NAME = "Test Camera" TEST_CAMERA_ENTITY_ID = "camera.test_camera" diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 8ef58cc968d..d9a9a847b63 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -45,8 +45,8 @@ from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from homeassistant.util.aiohttp import MockRequest -import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index fae7fccbb6d..bc345c0b66f 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index da6fbae32a3..3b97c8aa7fe 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 @@ -21,7 +21,9 @@ MOCK_USER_INPUT = { CONF_PORT: PORT, } -MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( +MOCK_PIN_INPUT = {CONF_PIN: 1234} + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), @@ -31,7 +33,7 @@ MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( properties={"txtvers": "1", "model": "TVM 7675"}, ) -MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 4de23de63c9..1fa2715595d 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -1,20 +1,23 @@ """Tests for the Vogel's MotionMount config flow.""" import dataclasses +from datetime import timedelta import socket from unittest.mock import MagicMock, PropertyMock +from freezegun.api import FrozenDateTimeFactory import motionmount import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( HOST, + MOCK_PIN_INPUT, MOCK_USER_INPUT, MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, @@ -24,23 +27,12 @@ from . import ( ZEROCONF_NAME, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MAC = bytes.fromhex("c4dd57f8a55f") pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - - async def test_user_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -117,33 +109,6 @@ async def test_user_not_connected_error( assert result["reason"] == "not_connected" -async def test_user_response_error_single_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow creates an entry when there is a response error.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - - assert result["data"] - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - - assert result["result"] - - async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -199,30 +164,6 @@ async def test_user_response_error_single_device_new_ce_new_pro( assert result["result"].unique_id == ZEROCONF_MAC -async def test_user_response_error_multi_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow is aborted when there are multiple devices.""" - mock_config_entry.add_to_hass(hass) - - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -246,6 +187,53 @@ async def test_user_response_error_multi_device_new_ce_new_pro( assert result["reason"] == "already_configured" +async def test_user_response_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_zeroconf_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -322,48 +310,6 @@ async def test_zeroconf_not_connected_error( assert result["reason"] == "not_connected" -async def test_show_zeroconf_form_old_ce_old_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - -async def test_show_zeroconf_form_old_ce_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -384,6 +330,21 @@ async def test_show_zeroconf_form_new_ce_old_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id is None + async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, @@ -403,6 +364,21 @@ async def test_show_zeroconf_form_new_ce_new_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + async def test_zeroconf_device_exists_abort( hass: HomeAssistant, @@ -423,6 +399,346 @@ async def test_zeroconf_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_incorrect_then_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + assert result["errors"] + assert result["errors"][CONF_PIN] == CONF_PIN + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_first_incorrect_pin_to_backoff( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + side_effect=[True, 1] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert mock_motionmount_config_flow.authenticate.called + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_multiple_incorrect_pins( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_show_backoff_when_still_running( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + # This situation happens when the user cancels the progress dialog and tries to + # configure the MotionMount again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_full_user_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -459,7 +775,7 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, ) -> None: - """Test the full manual user flow from start to finish.""" + """Test the full zeroconf flow from start to finish.""" type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) @@ -487,3 +803,37 @@ async def test_full_zeroconf_flow_implementation( assert result["result"] assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_reauth_flow_implementation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py new file mode 100644 index 00000000000..0320e62d640 --- /dev/null +++ b/tests/components/motionmount/test_sensor.py @@ -0,0 +1,49 @@ +"""Tests for the MotionMount Sensor platform.""" + +from unittest.mock import patch + +from motionmount import MotionMountSystemError +import pytest + +from homeassistant.core import HomeAssistant + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("system_status", "state"), + [ + (None, "none"), + (MotionMountSystemError.MotorError, "motor"), + (MotionMountSystemError.ObstructionDetected, "obstruction"), + (MotionMountSystemError.TVWidthConstraintError, "tv_width_constraint"), + (MotionMountSystemError.HDMICECError, "hdmi_cec"), + (MotionMountSystemError.InternalError, "internal"), + ], +) +async def test_error_status_sensor_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + system_status: MotionMountSystemError, + state: str, +) -> None: + """Tests the state attributes.""" + with patch( + "homeassistant.components.motionmount.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + motionmount_mock.return_value.name = ZEROCONF_NAME + motionmount_mock.return_value.mac = MAC + motionmount_mock.return_value.is_authenticated = True + motionmount_mock.return_value.system_status = [system_status] + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert hass.states.get("sensor.my_motionmount_error_status").state == state diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 22f0416a2c6..efe5d0f1a4e 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import AsyncGenerator, Generator +from pathlib import Path from random import getrandbits from typing import Any from unittest.mock import AsyncMock, patch @@ -18,7 +19,6 @@ from tests.common import MockConfigEntry from tests.typing import MqttMockPahoClient ENTRY_DEFAULT_BIRTH_MESSAGE = { - mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "homeassistant/status", mqtt.ATTR_PAYLOAD: "online", @@ -39,14 +39,23 @@ def temp_dir_prefix() -> str: return "test" -@pytest.fixture -def mock_temp_dir(temp_dir_prefix: str) -> Generator[str]: +@pytest.fixture(autouse=True) +async def mock_temp_dir( + hass: HomeAssistant, tmp_path: Path, temp_dir_prefix: str +) -> AsyncGenerator[str]: """Mock the certificate temp directory.""" - with patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}", - ) as mocked_temp_dir: + mqtt_temp_dir = f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}" + with ( + patch( + "homeassistant.components.mqtt.util.tempfile.gettempdir", + return_value=tmp_path, + ), + patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + mqtt_temp_dir, + ) as mocked_temp_dir, + ): yield mocked_temp_dir @@ -77,6 +86,7 @@ def mock_debouncer(hass: HomeAssistant) -> Generator[asyncio.Event]: async def setup_with_birth_msg_client_mock( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_client_mock: MqttMockPahoClient, ) -> AsyncGenerator[MqttMockPahoClient]: """Test sending birth message.""" @@ -89,6 +99,9 @@ async def setup_with_birth_msg_client_mock( entry = MockConfigEntry( domain=mqtt.DOMAIN, data=mqtt_config_entry_data or {mqtt.CONF_BROKER: "test-broker"}, + options=mqtt_config_entry_options or {}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index d27163c3423..8809f2201f2 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, @@ -1034,6 +1034,7 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.usefixtures("mock_temp_dir") @pytest.mark.parametrize( ("hass_config", "payload1", "state1", "payload2", "state2"), [ diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 1daad0e3914..9d5401fd437 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -32,6 +32,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, ) @@ -94,7 +95,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mqtt_client.on_connect, mqtt_client, None, 0, 0, 0 + mqtt_client.on_connect, mqtt_client, None, 0, MockMqttReasonCode() ), ) mqtt_client.publish = MagicMock(return_value=FakeInfo()) @@ -105,6 +106,8 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt.CONF_BROKER: "test-broker", mqtt.CONF_DISCOVERY: False, }, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -117,7 +120,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: ) await asyncio.sleep(0) # Simulate late ACK callback from client with mid 100 - mqtt_client.on_publish(0, 0, 100) + mqtt_client.on_publish(0, 0, 100, MockMqttReasonCode(), None) # disconnect the MQTT client await hass.async_stop() await hass.async_block_till_done() @@ -132,7 +135,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: await hass.async_block_till_done(wait_background_tasks=True) -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_publish( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient ) -> None: @@ -776,10 +779,10 @@ async def test_replaying_payload_same_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() # Simulate a (retained) message played back after reconnecting @@ -906,10 +909,10 @@ async def test_replaying_payload_wildcard_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() @@ -1022,8 +1025,8 @@ async def test_unsubscribe_race( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1043,7 +1046,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) # Test to subscribe orther topic while the client is not connected await mqtt.async_subscribe(hass, "test/other", record_calls) @@ -1051,7 +1054,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) @@ -1059,8 +1062,8 @@ async def test_restore_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, @@ -1087,10 +1090,10 @@ async def test_restore_all_active_subscriptions_on_reconnect( unsub() assert mqtt_client_mock.unsubscribe.call_count == 0 - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) # wait for cooldown await mock_debouncer.wait() @@ -1100,8 +1103,8 @@ async def test_restore_all_active_subscriptions_on_reconnect( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribed_at_highest_qos( hass: HomeAssistant, @@ -1136,7 +1139,12 @@ async def test_initial_setup_logs_error( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test for setup failure if initial client connection fails.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.side_effect = MagicMock(return_value=1) try: @@ -1153,27 +1161,37 @@ async def test_logs_error_if_no_connect_broker( ) -> None: """Test for setup failure if connection to broker is missing.""" mqtt_client_mock = setup_with_birth_msg_client_mock - # test with rc = 3 -> broker unavailable - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, 3) - await hass.async_block_till_done() - assert ( - "Unable to connect to the MQTT broker: Connection Refused: broker unavailable." - in caplog.text + # test with reason code = 136 -> server unavailable + mqtt_client_mock.on_disconnect(Mock(), None, None, MockMqttReasonCode()) + mqtt_client_mock.on_connect( + Mock(), + None, + None, + MockMqttReasonCode(value=136, is_failure=True, name="Server unavailable"), ) + await hass.async_block_till_done() + assert "Unable to connect to the MQTT broker: Server unavailable" in caplog.text -@pytest.mark.parametrize("return_code", [4, 5]) +@pytest.mark.parametrize( + "reason_code", + [ + MockMqttReasonCode( + value=134, is_failure=True, name="Bad user name or password" + ), + MockMqttReasonCode(value=135, is_failure=True, name="Not authorized"), + ], +) async def test_triggers_reauth_flow_if_auth_fails( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient, - return_code: int, + reason_code: MockMqttReasonCode, ) -> None: """Test re-auth is triggered if authentication is failing.""" mqtt_client_mock = setup_with_birth_msg_client_mock # test with rc = 4 -> CONNACK_REFUSED_NOT_AUTHORIZED and 5 -> CONNACK_REFUSED_BAD_USERNAME_PASSWORD - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, return_code) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode(), None) + mqtt_client_mock.on_connect(Mock(), None, None, reason_code) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -1190,7 +1208,9 @@ async def test_handle_mqtt_on_callback( mqtt_client_mock = setup_with_birth_msg_client_mock with patch.object(mqtt_client_mock, "get_mid", return_value=100): # Simulate an ACK for mid == 100, this will call mqtt_mock._async_get_mid_future(mid) - mqtt_client_mock.on_publish(mqtt_client_mock, None, 100) + mqtt_client_mock.on_publish( + mqtt_client_mock, None, 100, MockMqttReasonCode(), None + ) await hass.async_block_till_done() # Make sure the ACK has been received await hass.async_block_till_done() @@ -1212,7 +1232,7 @@ async def test_handle_mqtt_on_callback_after_cancellation( # Simulate the mid future getting a cancellation mqtt_mock()._async_get_mid_future(101).cancel() # Simulate an ACK for mid == 101, being received after the cancellation - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1229,7 +1249,7 @@ async def test_handle_mqtt_on_callback_after_timeout( # Simulate the mid future getting a timeout mqtt_mock()._async_get_mid_future(101).set_exception(asyncio.TimeoutError) # Simulate an ACK for mid == 101, being received after the timeout - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1239,14 +1259,19 @@ async def test_publish_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test publish error.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) # simulate an Out of memory error with patch( "homeassistant.components.mqtt.async_client.AsyncMQTTClient" ) as mock_client: - mock_client().connect = lambda *args: 1 + mock_client().connect = lambda **kwargs: 1 mock_client().publish().rc = 1 assert await hass.config_entries.async_setup(entry.entry_id) with pytest.raises(HomeAssistantError): @@ -1305,7 +1330,7 @@ async def test_handle_message_callback( @pytest.mark.parametrize( - ("mqtt_config_entry_data", "protocol"), + ("mqtt_config_entry_data", "protocol", "clean_session"), [ ( { @@ -1313,6 +1338,7 @@ async def test_handle_message_callback( CONF_PROTOCOL: "3.1", }, 3, + True, ), ( { @@ -1320,6 +1346,7 @@ async def test_handle_message_callback( CONF_PROTOCOL: "3.1.1", }, 4, + True, ), ( { @@ -1327,22 +1354,72 @@ async def test_handle_message_callback( CONF_PROTOCOL: "5", }, 5, + None, ), ], + ids=["v3.1", "v3.1.1", "v5"], ) -async def test_setup_mqtt_client_protocol( - mqtt_mock_entry: MqttMockHAClientGenerator, protocol: int +async def test_setup_mqtt_client_clean_session_and_protocol( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, + protocol: int, + clean_session: bool | None, ) -> None: - """Test MQTT client protocol setup.""" + """Test MQTT client clean_session and protocol setup.""" with patch( "homeassistant.components.mqtt.async_client.AsyncMQTTClient" ) as mock_client: await mqtt_mock_entry() + # check if clean_session was correctly + assert mock_client.call_args[1]["clean_session"] == clean_session + # check if protocol setup was correctly assert mock_client.call_args[1]["protocol"] == protocol +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "connect_args"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1", + }, + call(host="mock-broker", port=1883, keepalive=60, clean_start=3), + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1.1", + }, + call(host="mock-broker", port=1883, keepalive=60, clean_start=3), + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "5", + }, + call(host="mock-broker", port=1883, keepalive=60, clean_start=True), + ), + ], + ids=["v3.1", "v3.1.1", "v5"], +) +async def test_setup_mqtt_client_clean_start( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, + connect_args: tuple[Any], +) -> None: + """Test MQTT client protocol connects with `clean_start` set correctly.""" + await mqtt_mock_entry() + + # check if clean_start was set correctly + assert len(mqtt_client_mock.connect.mock_calls) == 1 + assert mqtt_client_mock.connect.mock_calls[0] == connect_args + + @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) async def test_handle_mqtt_timeout_on_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_debouncer: asyncio.Event @@ -1376,12 +1453,15 @@ async def test_handle_mqtt_timeout_on_callback( mock_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ), ) entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -1414,7 +1494,12 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, exception: Exception ) -> None: """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with patch( @@ -1495,17 +1580,19 @@ async def test_tls_version( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: { - mqtt.ATTR_TOPIC: "birth", - mqtt.ATTR_PAYLOAD: "birth", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "birth", + mqtt.ATTR_PAYLOAD: "birth", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + } }, - } + ) ], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -1515,11 +1602,18 @@ async def test_custom_birth_message( hass: HomeAssistant, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1533,7 +1627,7 @@ async def test_custom_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_default_birth_message( @@ -1548,8 +1642,8 @@ async def test_default_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_BIRTH_MESSAGE: {}})], ) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @@ -1559,10 +1653,17 @@ async def test_no_birth_message( record_calls: MessageCallbackType, mock_debouncer: asyncio.Event, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test disabling birth message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) mock_debouncer.clear() @@ -1582,20 +1683,27 @@ async def test_no_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [ENTRY_DEFAULT_BIRTH_MESSAGE], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, ENTRY_DEFAULT_BIRTH_MESSAGE)], ) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.2) async def test_delayed_birth_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test sending birth message does not happen until Home Assistant starts.""" hass.set_state(CoreState.starting) await hass.async_block_till_done() birth = asyncio.Event() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1619,7 +1727,7 @@ async def test_delayed_birth_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_subscription_done_when_birth_message_is_sent( @@ -1637,26 +1745,37 @@ async def test_subscription_done_when_birth_message_is_sent( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_WILL_MESSAGE: { - mqtt.ATTR_TOPIC: "death", - mqtt.ATTR_PAYLOAD: "death", - mqtt.ATTR_QOS: 0, - mqtt.ATTR_RETAIN: False, + ( + { + mqtt.CONF_BROKER: "mock-broker", }, - } + { + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "death", + mqtt.ATTR_PAYLOAD: "death", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + }, + }, + ) ], ) async def test_custom_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1678,16 +1797,23 @@ async def test_default_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_WILL_MESSAGE: {}})], ) async def test_no_will_message( hass: HomeAssistant, mqtt_config_entry_data: dict[str, Any], + mqtt_config_entry_options: dict[str, Any], mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test will message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=mqtt_config_entry_data, + options=mqtt_config_entry_options, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) assert await hass.config_entries.async_setup(entry.entry_id) @@ -1697,7 +1823,7 @@ async def test_no_will_message( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_subscribes_topics_on_connect( @@ -1716,12 +1842,12 @@ async def test_mqtt_subscribes_topics_on_connect( await mqtt.async_subscribe(hass, "still/pending", record_calls, 1) await mock_debouncer.wait() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1730,7 +1856,7 @@ async def test_mqtt_subscribes_topics_on_connect( assert ("still/pending", 1) in subscribe_calls -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) async def test_mqtt_subscribes_wildcard_topics_in_correct_order( hass: HomeAssistant, mock_debouncer: asyncio.Event, @@ -1776,12 +1902,12 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( # Assert the initial wildcard topic subscription order _assert_subscription_order() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() # Assert the wildcard topic subscription order after a reconnect @@ -1789,7 +1915,7 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE | {mqtt.CONF_DISCOVERY: False}], ) async def test_mqtt_discovery_not_subscribes_when_disabled( @@ -1807,12 +1933,12 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( assert (f"homeassistant/{component}/+/config", 0) not in subscribe_calls assert (f"homeassistant/{component}/+/+/config", 0) not in subscribe_calls - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1822,7 +1948,7 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( @pytest.mark.parametrize( - "mqtt_config_entry_data", + "mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE], ) async def test_mqtt_subscribes_in_single_call( @@ -1848,7 +1974,7 @@ async def test_mqtt_subscribes_in_single_call( ] -@pytest.mark.parametrize("mqtt_config_entry_data", [ENTRY_DEFAULT_BIRTH_MESSAGE]) +@pytest.mark.parametrize("mqtt_config_entry_options", [ENTRY_DEFAULT_BIRTH_MESSAGE]) @patch("homeassistant.components.mqtt.client.MAX_SUBSCRIBES_PER_CALL", 2) @patch("homeassistant.components.mqtt.client.MAX_UNSUBSCRIBES_PER_CALL", 2) async def test_mqtt_subscribes_and_unsubscribes_in_chunks( @@ -1907,7 +2033,7 @@ async def test_auto_reconnect( mqtt_client_mock.reconnect.reset_mock() mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() mqtt_client_mock.reconnect.side_effect = exception("foo") @@ -1928,7 +2054,7 @@ async def test_auto_reconnect( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() async_fire_time_changed( @@ -1970,7 +2096,7 @@ async def test_server_sock_connect_and_disconnect( mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_CONN_LOST mqtt_client_mock.on_socket_unregister_write(mqtt_client_mock, None, client) mqtt_client_mock.on_socket_close(mqtt_client_mock, None, client) - mqtt_client_mock.on_disconnect(mqtt_client_mock, None, client) + mqtt_client_mock.on_disconnect(mqtt_client_mock, None, None, MockMqttReasonCode()) await hass.async_block_till_done() mock_debouncer.clear() unsub() @@ -2021,7 +2147,7 @@ async def test_server_sock_buffer_size_with_websocket( client.setblocking(False) server.setblocking(False) - class FakeWebsocket(paho_mqtt.WebsocketWrapper): + class FakeWebsocket(paho_mqtt._WebsocketWrapper): def _do_handshake(self, *args, **kwargs): pass @@ -2108,4 +2234,4 @@ async def test_loop_write_failure( # Final for the disconnect callback await hass.async_block_till_done() - assert "Disconnected from MQTT server test-broker:1883" in caplog.text + assert "Error returned from MQTT server: The connection was lost." in caplog.text diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 5edd73e3f5a..3760b0226f5 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_ACTION, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -85,6 +86,7 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ "eco", @@ -111,6 +113,7 @@ async def test_setup_params( assert state.attributes.get("temperature") == 21 assert state.attributes.get("fan_mode") == "low" assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" assert state.state == "off" assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP @@ -123,6 +126,7 @@ async def test_setup_params( | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -159,6 +163,7 @@ async def test_supported_features( state = hass.states.get(ENTITY_CLIMATE) support = ( ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE @@ -562,12 +567,29 @@ async def test_set_swing_mode_bad_attr( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" + with pytest.raises(vol.Invalid) as excinfo: + await common.async_set_swing_horizontal_mode(hass, None, ENTITY_CLIMATE) # type:ignore[arg-type] + assert ( + "string value is None for dictionary value @ data['swing_horizontal_mode']" + in str(excinfo.value) + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize( "hass_config", [ help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + }, + ), ) ], ) @@ -579,19 +601,32 @@ async def test_set_swing_pessimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + assert state.attributes.get("swing_horizontal_mode") is None await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-state", "on") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "on") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + @pytest.mark.parametrize( "hass_config", @@ -599,7 +634,13 @@ async def test_set_swing_pessimistic( help_custom_config( climate.DOMAIN, DEFAULT_CONFIG, - ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + "optimistic": True, + }, + ), ) ], ) @@ -611,19 +652,32 @@ async def test_set_swing_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "off") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "off") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( @@ -638,6 +692,15 @@ async def test_set_swing( mqtt_mock.async_publish.assert_called_once_with("swing-mode-topic", "on", 0, False) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + mqtt_mock.reset_mock() + + assert state.attributes.get("swing_horizontal_mode") == "off" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "on", 0, False + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1337,6 +1400,7 @@ async def test_get_target_temperature_low_high_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1359,6 +1423,7 @@ async def test_get_target_temperature_low_high_with_templates( "action_topic": "action", "mode_state_topic": "mode-state", "fan_mode_state_topic": "fan-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", "swing_mode_state_topic": "swing-state", "temperature_state_topic": "temperature-state", "target_humidity_state_topic": "humidity-state", @@ -1396,6 +1461,12 @@ async def test_get_with_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + # Swing Horizontal Mode + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-horizontal-state", '"on"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Temperature - with valid value assert state.attributes.get("temperature") is None async_fire_mqtt_message(hass, "temperature-state", '"1031"') @@ -1495,6 +1566,7 @@ async def test_get_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1511,6 +1583,7 @@ async def test_get_with_templates( "power_command_template": "power: {{ value }}", "preset_mode_command_template": "preset_mode: {{ value }}", "mode_command_template": "mode: {{ value }}", + "swing_horizontal_mode_command_template": "swing_horizontal_mode: {{ value }}", "swing_mode_command_template": "swing_mode: {{ value }}", "temperature_command_template": "temp: {{ value }}", "temperature_high_command_template": "temp_hi: {{ value }}", @@ -1580,6 +1653,15 @@ async def test_set_and_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" + # Swing Horizontal Mode + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "swing_horizontal_mode: on", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Swing Mode await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) mqtt_mock.async_publish.assert_called_once_with( @@ -1940,6 +2022,7 @@ async def test_unique_id( ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), ("mode_state_topic", "cool", None, None), ("mode_state_topic", "fan_only", None, None), + ("swing_horizontal_mode_state_topic", "on", ATTR_SWING_HORIZONTAL_MODE, "on"), ("swing_mode_state_topic", "on", ATTR_SWING_MODE, "on"), ("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1), ("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9), @@ -2178,6 +2261,13 @@ async def test_precision_whole( "medium", "fan_mode_command_template", ), + ( + climate.SERVICE_SET_SWING_HORIZONTAL_MODE, + "swing_horizontal_mode_command_topic", + {"swing_horizontal_mode": "on"}, + "on", + "swing_horizontal_mode_command_template", + ), ( climate.SERVICE_SET_SWING_MODE, "swing_mode_command_topic", @@ -2378,6 +2468,7 @@ async def test_unload_entry( "current_temperature_topic": "current-temperature-topic", "preset_mode_state_topic": "preset-mode-state-topic", "preset_modes": ["eco", "away"], + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", "swing_mode_state_topic": "swing-mode-state-topic", "target_humidity_state_topic": "target-humidity-state-topic", "temperature_high_state_topic": "temperature-high-state-topic", @@ -2399,6 +2490,7 @@ async def test_unload_entry( ("current-humidity-topic", "45", "46"), ("current-temperature-topic", "18.0", "18.1"), ("preset-mode-state-topic", "eco", "away"), + ("swing-horizontal-mode-state-topic", "on", "off"), ("swing-mode-state-topic", "on", "off"), ("target-humidity-state-topic", "45", "50"), ("temperature-state-topic", "18", "19"), diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index fbf393dc105..3bb8657e2f2 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1854,9 +1854,14 @@ async def help_test_reload_with_config( ) -> None: """Test reloading with supplied config.""" new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(config) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config + + def _write_yaml_config() -> None: + new_yaml_config = yaml.dump(config) + new_yaml_config_file.write_text(new_yaml_config) + assert new_yaml_config_file.read_text() == new_yaml_config + return new_yaml_config + + await hass.async_add_executor_job(_write_yaml_config) with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): await hass.services.async_call( @@ -1887,7 +1892,12 @@ async def help_test_reloadable( mqtt.DOMAIN: {domain: [old_config_1, old_config_2]}, } # Start the MQTT entry with the old config - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 38dbda50cdd..f39e32a0d8b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockMqttReasonCode from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -40,8 +40,59 @@ ADD_ON_DISCOVERY_INFO = { "protocol": "3.1.1", "ssl": False, } -MOCK_CLIENT_CERT = b"## mock client certificate file ##" -MOCK_CLIENT_KEY = b"## mock key file ##" + +MOCK_CA_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock CA certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_GENERIC_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock generic certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CA_CERT_DER = b"## mock DER formatted CA certificate file ##\n" +MOCK_CLIENT_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock client certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CLIENT_CERT_DER = b"## mock DER formatted client certificate file ##\n" +MOCK_CLIENT_KEY = ( + b"-----BEGIN PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END PRIVATE KEY-----" +) +MOCK_ENCRYPTED_CLIENT_KEY = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + b"## mock client key file ##\n" + b"-----END ENCRYPTED PRIVATE KEY-----" +) +MOCK_CLIENT_KEY_DER = b"## mock DER formatted key file ##\n" +MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n" + + +MOCK_ENTRY_DATA = { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} +MOCK_ENTRY_OPTIONS = { + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/online", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 1, + mqtt.ATTR_RETAIN: True, + }, + mqtt.CONF_WILL_MESSAGE: { + mqtt.ATTR_TOPIC: "ha_state/offline", + mqtt.ATTR_PAYLOAD: "offline", + mqtt.ATTR_QOS: 2, + mqtt.ATTR_RETAIN: False, + }, +} @pytest.fixture(autouse=True) @@ -80,15 +131,27 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock]]: patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, patch( "homeassistant.components.mqtt.config_flow.load_pem_private_key" - ) as mock_key_check, + ) as mock_pem_key_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_private_key" + ) as mock_der_key_check, patch( "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" - ) as mock_cert_check, + ) as mock_pem_cert_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" + ) as mock_der_cert_check, ): + mock_pem_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT + mock_der_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, - "load_pem_x509_certificate": mock_cert_check, - "load_pem_private_key": mock_key_check, + "load_der_private_key": mock_der_key_check, + "load_der_x509_certificate": mock_der_cert_check, + "load_pem_private_key": mock_pem_key_check, + "load_pem_x509_certificate": mock_pem_cert_check, } @@ -121,16 +184,16 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient]: def loop_start(): """Simulate connect on loop start.""" - mock_client().on_connect(mock_client, None, None, 0) + mock_client().on_connect(mock_client, None, None, MockMqttReasonCode(), None) def _subscribe(topic, qos=0): mid = get_mid() - mock_client().on_subscribe(mock_client, 0, mid) + mock_client().on_subscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) def _unsubscribe(topic): mid = get_mid() - mock_client().on_unsubscribe(mock_client, 0, mid) + mock_client().on_unsubscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) with patch( @@ -158,9 +221,31 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: yield mock_client() +@pytest.fixture +def mock_ca_cert() -> bytes: + """Mock the CA certificate.""" + return MOCK_CA_CERT + + +@pytest.fixture +def mock_client_cert() -> bytes: + """Mock the client certificate.""" + return MOCK_CLIENT_CERT + + +@pytest.fixture +def mock_client_key() -> bytes: + """Mock the client key.""" + return MOCK_CLIENT_KEY + + @pytest.fixture def mock_process_uploaded_file( - tmp_path: Path, mock_temp_dir: str + tmp_path: Path, + mock_ca_cert: bytes, + mock_client_cert: bytes, + mock_client_key: bytes, + mock_temp_dir: str, ) -> Generator[MagicMock]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) @@ -173,15 +258,15 @@ def mock_process_uploaded_file( ) -> Iterator[Path | None]: if file_id == file_id_ca: with open(tmp_path / "ca.crt", "wb") as cafile: - cafile.write(b"## mock CA certificate file ##") + cafile.write(mock_ca_cert) yield tmp_path / "ca.crt" elif file_id == file_id_cert: with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(b"## mock client certificate file ##") + certfile.write(mock_client_cert) yield tmp_path / "client.crt" elif file_id == file_id_key: with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(b"## mock key file ##") + keyfile.write(mock_client_key) yield tmp_path / "client.key" else: pytest.fail(f"Unexpected file_id: {file_id}") @@ -243,8 +328,10 @@ async def test_user_connection_works( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } + # Check we have the latest Config Entry version + assert result["result"].version == 1 + assert result["result"].minor_version == 2 # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 # Check config entry got setup @@ -283,7 +370,6 @@ async def test_user_connection_works_with_supervisor( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection assert len(mock_try_connection.mock_calls) == 1 @@ -324,7 +410,6 @@ async def test_user_v5_connection_works( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].data == { "broker": "another-broker", - "discovery": True, "port": 2345, "protocol": "5", } @@ -383,14 +468,12 @@ async def test_manual_config_set( assert result["result"].data == { "broker": "127.0.0.1", "port": 1883, - "discovery": True, } # Check we tried the connection, with precedence for config entry settings mock_try_connection.assert_called_once_with( { "broker": "127.0.0.1", "port": 1883, - "discovery": True, }, ) # Check config entry got setup @@ -401,7 +484,11 @@ async def test_manual_config_set( async def test_user_single_instance(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_USER} @@ -412,7 +499,11 @@ async def test_user_single_instance(hass: HomeAssistant) -> None: async def test_hassio_already_configured(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" - MockConfigEntry(domain="mqtt").add_to_hass(hass) + MockConfigEntry( + domain="mqtt", + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_HASSIO} @@ -424,7 +515,10 @@ async def test_hassio_already_configured(hass: HomeAssistant) -> None: async def test_hassio_ignored(hass: HomeAssistant) -> None: """Test we supervisor discovered instance can be ignored.""" MockConfigEntry( - domain=mqtt.DOMAIN, source=config_entries.SOURCE_IGNORE + domain=mqtt.DOMAIN, + source=config_entries.SOURCE_IGNORE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -934,43 +1028,19 @@ async def test_addon_not_installed_failures( async def test_option_flow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - mock_try_connection: MagicMock, ) -> None: """Test config flow options.""" with patch( "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value={}) ) as yaml_mock: - mqtt_mock = await mqtt_mock_entry() - mock_try_connection.return_value = True + await mqtt_mock_entry() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - - mqtt_mock.async_connect.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 yaml_mock.reset_mock() @@ -992,12 +1062,10 @@ async def test_option_flow( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.data == {mqtt.CONF_BROKER: "mock-broker"} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: { @@ -1015,17 +1083,41 @@ async def test_option_flow( } await hass.async_block_till_done() - assert config_entry.title == "another-broker" - # assert that the entry was reloaded with the new config + # assert that the entry was reloaded with the new config assert yaml_mock.await_count +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) @pytest.mark.parametrize( "test_error", [ "bad_certificate", "bad_client_cert", - "bad_client_key", + "client_key_error", "bad_client_cert_key", "invalid_inclusion", None, @@ -1038,31 +1130,54 @@ async def test_bad_certificate( mock_ssl_context: dict[str, MagicMock], mock_process_uploaded_file: MagicMock, test_error: str | None, + client_key_password: str, + mock_ca_cert: bytes, ) -> None: """Test bad certificate tests.""" + + def _side_effect_on_client_cert(data: bytes) -> MagicMock: + """Raise on client cert only. + + The function is called twice, once for the CA chain + and once for the client cert. We only want to raise on a client cert. + """ + if data == MOCK_CLIENT_CERT_DER: + raise ValueError + mock_certificate_side_effect = MagicMock() + mock_certificate_side_effect().public_bytes.return_value = MOCK_GENERIC_CERT + return mock_certificate_side_effect + # Mock certificate files file_id = mock_process_uploaded_file.file_id + set_ca_cert = "custom" + set_client_cert = True + tls_insecure = False test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], - "set_ca_cert": True, + "client_key_password": client_key_password, + "set_ca_cert": set_ca_cert, "set_client_cert": True, } - set_client_cert = True - set_ca_cert = "custom" - tls_insecure = False if test_error == "bad_certificate": # CA chain is not loading mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + # Fail on the CA cert if DER encoded + mock_ssl_context["load_der_x509_certificate"].side_effect = ValueError elif test_error == "bad_client_cert": # Client certificate is invalid mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError - elif test_error == "bad_client_key": + # Fail on the client cert if DER encoded + mock_ssl_context[ + "load_der_x509_certificate" + ].side_effect = _side_effect_on_client_cert + elif test_error == "client_key_error": # Client key file is invalid mock_ssl_context["load_pem_private_key"].side_effect = ValueError + mock_ssl_context["load_der_private_key"].side_effect = ValueError elif test_error == "bad_client_cert_key": # Client key file file and certificate do not pair mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError @@ -1071,7 +1186,7 @@ async def test_bad_certificate( test_input.pop(mqtt.CONF_CLIENT_KEY) mqtt_mock = await mqtt_mock_entry() - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1088,11 +1203,11 @@ async def test_bad_certificate( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1109,14 +1224,14 @@ async def test_bad_certificate( test_input["set_ca_cert"] = set_ca_cert test_input["tls_insecure"] = tls_insecure - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) if test_error is not None: assert result["errors"]["base"] == test_error return - assert result["errors"] == {} + assert "errors" not in result @pytest.mark.parametrize( @@ -1148,7 +1263,7 @@ async def test_keepalive_validation( mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] # Add at least one advanced option to get the full form hass.config_entries.async_update_entry( config_entry, @@ -1161,22 +1276,23 @@ async def test_keepalive_validation( mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" if error: with pytest.raises(vol.Invalid): - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) return - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert not result["errors"] + assert "errors" not in result + assert result["reason"] == "reconfigure_successful" async def test_disable_birth_will( @@ -1186,7 +1302,7 @@ async def test_disable_birth_will( mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" - mqtt_mock = await mqtt_mock_entry() + await mqtt_mock_entry() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] hass.config_entries.async_update_entry( @@ -1199,26 +1315,10 @@ async def test_disable_birth_will( await hass.async_block_till_done() mock_reload_after_entry_update.reset_mock() - mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" - await hass.async_block_till_done() - assert mqtt_mock.async_connect.call_count == 0 result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1238,12 +1338,14 @@ async def test_disable_birth_will( }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {} - assert config_entry.data == { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", + assert result["data"] == { + "birth_message": {}, + "discovery": True, + "discovery_prefix": "homeassistant", + "will_message": {}, + } + assert config_entry.data == {mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234} + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", mqtt.CONF_BIRTH_MESSAGE: {}, @@ -1270,6 +1372,8 @@ async def test_invalid_discovery_prefix( data={ mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", }, @@ -1280,16 +1384,6 @@ async def test_invalid_discovery_prefix( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" await hass.async_block_till_done() @@ -1308,6 +1402,8 @@ async def test_invalid_discovery_prefix( assert config_entry.data == { mqtt.CONF_BROKER: "test-broker", CONF_PORT: 1234, + } + assert config_entry.options == { mqtt.CONF_DISCOVERY: True, mqtt.CONF_DISCOVERY_PREFIX: "homeassistant", } @@ -1356,6 +1452,8 @@ async def test_option_flow_default_suggested_values( CONF_PORT: 1234, CONF_USERNAME: "user", CONF_PASSWORD: "pass", + }, + options={ mqtt.CONF_DISCOVERY: True, mqtt.CONF_BIRTH_MESSAGE: { mqtt.ATTR_TOPIC: "ha_state/online", @@ -1371,37 +1469,13 @@ async def test_option_flow_default_suggested_values( }, }, ) + await hass.async_block_till_done() # Test default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - } - suggested = { - CONF_USERNAME: "user", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - CONF_USERNAME: "us3r", - CONF_PASSWORD: "p4ss", - }, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: True, "birth_qos": 1, "birth_retain": True, "will_qos": 2, @@ -1421,7 +1495,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: False, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1437,28 +1510,8 @@ async def test_option_flow_default_suggested_values( # Test updated default/suggested values from config result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - } - suggested = { - CONF_USERNAME: "us3r", - CONF_PASSWORD: PWD_NOT_CHANGED, - } - for key, value in defaults.items(): - assert get_default(result["data_schema"].schema, key) == value - for key, value in suggested.items(): - assert get_suggested(result["data_schema"].schema, key) == value - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" defaults = { - mqtt.CONF_DISCOVERY: False, "birth_qos": 2, "birth_retain": False, "will_qos": 1, @@ -1478,7 +1531,6 @@ async def test_option_flow_default_suggested_values( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mqtt.CONF_DISCOVERY: True, "birth_topic": "ha_state/onl1ne", "birth_payload": "onl1ne", "birth_qos": 2, @@ -1496,7 +1548,8 @@ async def test_option_flow_default_suggested_values( @pytest.mark.parametrize( - ("advanced_options", "step_id"), [(False, "options"), (True, "broker")] + ("advanced_options", "flow_result"), + [(False, FlowResultType.ABORT), (True, FlowResultType.FORM)], ) @pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_skipping_advanced_options( @@ -1504,41 +1557,35 @@ async def test_skipping_advanced_options( mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, advanced_options: bool, - step_id: str, + flow_result: FlowResultType, ) -> None: """Test advanced options option.""" test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, - "advanced_options": advanced_options, } + if advanced_options: + test_input["advanced_options"] = True mqtt_mock = await mqtt_mock_entry() mock_try_connection.return_value = True - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - # Initiate with a basic setup - hass.config_entries.async_update_entry( - config_entry, - data={ - mqtt.CONF_BROKER: "test-broker", - CONF_PORT: 1234, - }, - ) - + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.async_connect.reset_mock() - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} + result = await config_entry.start_reconfigure_flow( + hass, show_advanced_options=advanced_options ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" - result = await hass.config_entries.options.async_configure( + assert ("advanced_options" in result["data_schema"].schema) == advanced_options + + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=test_input, ) - assert result["step_id"] == step_id + assert result["type"] is flow_result @pytest.mark.parametrize( @@ -1582,7 +1629,12 @@ async def test_step_reauth( """Test that the reauth step works.""" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=test_input) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=test_input, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1658,7 +1710,12 @@ async def test_step_hassio_reauth( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1740,7 +1797,12 @@ async def test_step_hassio_reauth_no_discovery_info( addon_info["hostname"] = "core-mosquitto" # Prepare the config entry - config_entry = MockConfigEntry(domain=mqtt.DOMAIN, data=entry_data) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data=entry_data, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1762,11 +1824,15 @@ async def test_step_hassio_reauth_no_discovery_info( mock_try_connection.assert_not_called() -async def test_options_user_connection_fails( +async def test_reconfigure_user_connection_fails( hass: HomeAssistant, mock_try_connection_time_out: MagicMock ) -> None: """Test if connection cannot be made.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1775,11 +1841,11 @@ async def test_options_user_connection_fails( CONF_PORT: 1234, }, ) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM mock_try_connection_time_out.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={mqtt.CONF_BROKER: "bad-broker", CONF_PORT: 2345}, ) @@ -1800,7 +1866,11 @@ async def test_options_bad_birth_message_fails( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient ) -> None: """Test bad birth message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1813,13 +1883,6 @@ async def test_options_bad_birth_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1841,7 +1904,11 @@ async def test_options_bad_will_message_fails( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test bad will message.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1854,13 +1921,6 @@ async def test_options_bad_will_message_fails( mock_try_connection.return_value = True result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345}, - ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options" @@ -1878,15 +1938,16 @@ async def test_options_bad_will_message_fails( } -@pytest.mark.parametrize( - "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] -) @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient ) -> None: """Test config flow with advanced parameters from config.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -1920,7 +1981,7 @@ async def test_try_connection_with_advanced_parameters( ) # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" defaults = { @@ -1944,9 +2005,8 @@ async def test_try_connection_with_advanced_parameters( assert get_suggested(result["data_schema"].schema, k) == v # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "another-broker", @@ -1961,9 +2021,8 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', }, ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" await hass.async_block_till_done() # check if the username and password was set from config flow and not from configuration.yaml @@ -1987,12 +2046,6 @@ async def test_try_connection_with_advanced_parameters( "/new/path", {"h3": "v3"}, ) - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() @@ -2005,7 +2058,11 @@ async def test_setup_with_advanced_settings( """Test config flow setup with advanced parameters.""" file_id = mock_process_uploaded_file.file_id - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2017,15 +2074,13 @@ async def test_setup_with_advanced_settings( mock_try_connection.return_value = True - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": True} - ) + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] # first iteration, basic settings - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2049,7 +2104,7 @@ async def test_setup_with_advanced_settings( assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema # second iteration, advanced settings with request for client cert - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2080,7 +2135,7 @@ async def test_setup_with_advanced_settings( assert result["data_schema"].schema[mqtt.CONF_WS_HEADERS] # third iteration, advanced settings with client cert and key set and bad json payload - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2105,7 +2160,7 @@ async def test_setup_with_advanced_settings( # fourth iteration, advanced settings with client cert and key set # and correct json payload for ws_headers - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ mqtt.CONF_BROKER: "test-broker", @@ -2124,17 +2179,8 @@ async def test_setup_with_advanced_settings( }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "options" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" # Check config entry result assert config_entry.data == { @@ -2143,8 +2189,8 @@ async def test_setup_with_advanced_settings( CONF_USERNAME: "user", CONF_PASSWORD: "secret", mqtt.CONF_KEEPALIVE: 30, - mqtt.CONF_CLIENT_CERT: "## mock client certificate file ##", - mqtt.CONF_CLIENT_KEY: "## mock key file ##", + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), "tls_insecure": True, mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_PATH: "/custom_path/", @@ -2153,8 +2199,155 @@ async def test_setup_with_advanced_settings( "header_2": "content_header_2", }, mqtt.CONF_CERTIFICATE: "auto", - mqtt.CONF_DISCOVERY: True, - mqtt.CONF_DISCOVERY_PREFIX: "homeassistant_test", + } + + +@pytest.mark.usefixtures("mock_ssl_context") +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) +async def test_setup_with_certificates( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mock_process_uploaded_file: MagicMock, + client_key_password: str, +) -> None: + """Test config flow setup with PEM and DER encoded certificates.""" + file_id = mock_process_uploaded_file.file_id + + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, + data={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + }, + ) + + mock_try_connection.return_value = True + + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["data_schema"].schema["advanced_options"] + + # first iteration, basic settings + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema + + # second iteration, advanced settings with request for client cert + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: False, + CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema["client_key_password"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CERTIFICATE] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + + # third iteration, advanced settings with client cert and key and CA certificate + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + "client_key_password": client_key_password, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check config entry result + assert config_entry.data == { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + "tls_insecure": False, + mqtt.CONF_TRANSPORT: "tcp", + mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"), } @@ -2163,7 +2356,11 @@ async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock ) -> None: """Test reconfiguration flow changing websockets transport settings.""" - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, @@ -2254,3 +2451,150 @@ async def test_reconfigure_flow_form( mqtt.CONF_WS_PATH: "/some_new_path", } await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "test-broker", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: "mqtt-password", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_path", + } + ], +) +async def test_reconfigure_no_changed_password( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reconfigure flow.""" + await mqtt_mock_entry() + entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "10.10.10,10", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: PWD_NOT_CHANGED, + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: '{"header_1": "custom_header1"}', + mqtt.CONF_WS_PATH: "/some_new_path", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + mqtt.CONF_BROKER: "10.10.10,10", + CONF_USERNAME: "mqtt-user", + CONF_PASSWORD: "mqtt-password", + CONF_PORT: 1234, + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_HEADERS: {"header_1": "custom_header1"}, + mqtt.CONF_WS_PATH: "/some_new_path", + } + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (1, 1, MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS, {}, 1, 2), + (1, 2, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 2), + (1, 3, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 1, 3), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock to a migratable or compatbible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + # Start MQTT + await mqtt_mock_entry() + await hass.async_block_till_done() + assert ( + config_entry.data | config_entry.options == MOCK_ENTRY_DATA | MOCK_ENTRY_OPTIONS + ) + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + +@pytest.mark.parametrize( + ( + "version", + "minor_version", + "data", + "options", + "expected_version", + "expected_minor_version", + ), + [ + (2, 1, MOCK_ENTRY_DATA, MOCK_ENTRY_OPTIONS, 2, 1), + ], +) +@pytest.mark.usefixtures("mock_reload_after_entry_update") +async def test_migrate_of_incompatible_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + version: int, + minor_version: int, + data: dict[str, Any], + options: dict[str, Any], + expected_version: int, + expected_minor_version: int, +) -> None: + """Test migrating a config entry.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + # Mock an incompatible config entry version + hass.config_entries.async_update_entry( + config_entry, + data=data, + options=options, + version=version, + minor_version=minor_version, + ) + await hass.async_block_till_done() + assert config_entry.version == expected_version + assert config_entry.minor_version == expected_minor_version + + # Try to start MQTT with incompatible config entry + with pytest.raises(AssertionError): + await mqtt_mock_entry() + + assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index b8499ba5812..bbd60329d0a 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -17,10 +17,12 @@ from tests.components.diagnostics import ( ) from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator -default_config = { - "birth_message": {}, +default_entry_data = { "broker": "mock-broker", } +default_entry_options = { + "birth_message": {}, +} async def test_entry_diagnostics( @@ -38,7 +40,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": {"entities": [], "triggers": []}, } @@ -123,7 +125,7 @@ async def test_entry_diagnostics( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "connected": True, "devices": [expected_device], - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @@ -132,20 +134,24 @@ async def test_entry_diagnostics( ) == { "connected": True, "device": expected_device, - "mqtt_config": default_config, + "mqtt_config": {"data": default_entry_data, "options": default_entry_options}, "mqtt_debug_info": expected_debug_info, } @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - CONF_PASSWORD: "hunter2", - CONF_USERNAME: "my_user", - } + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PASSWORD: "hunter2", + CONF_USERNAME: "my_user", + }, + { + mqtt.CONF_BIRTH_MESSAGE: {}, + }, + ) ], ) async def test_redact_diagnostics( @@ -157,9 +163,12 @@ async def test_redact_diagnostics( ) -> None: """Test redacting diagnostics.""" mqtt_mock = await mqtt_mock_entry() - expected_config = dict(default_config) - expected_config["password"] = "**REDACTED**" - expected_config["username"] = "**REDACTED**" + expected_config = { + "data": dict(default_entry_data), + "options": dict(default_entry_options), + } + expected_config["data"]["password"] = "**REDACTED**" + expected_config["data"]["username"] = "**REDACTED**" config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.connected = True diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 8a674a4e1cd..47c3a1e1988 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -195,8 +195,8 @@ async def mock_mqtt_flow( @pytest.mark.parametrize( - "mqtt_config_entry_data", - [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], + ("mqtt_config_entry_data", "mqtt_config_entry_options"), + [({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})], ) async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -356,7 +356,7 @@ async def test_invalid_device_discovery_config( async_fire_mqtt_message( hass, "homeassistant/device/bla/config", - '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, ' '"cmps": ""}', + '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, "cmps": ""}', ) await hass.async_block_till_done() assert ( @@ -1946,7 +1946,12 @@ async def test_cleanup_device_multiple_config_entries( mqtt_mock = await mqtt_mock_entry() ws_client = await hass_ws_client(hass) - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2042,7 +2047,12 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) -> None: """Test discovered device is cleaned up when removed through MQTT.""" mqtt_mock = await mqtt_mock_entry() - config_entry = MockConfigEntry(domain="test", data={}) + config_entry = MockConfigEntry( + domain="test", + data={}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -2370,7 +2380,6 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_PRECISION", "CONF_QOS", "CONF_SCHEMA", - "CONF_SWING_MODE_LIST", "CONF_TEMP_STEP", # Removed "CONF_WHITE_VALUE", @@ -2437,12 +2446,14 @@ async def test_no_implicit_state_topic_switch( @pytest.mark.parametrize( - "mqtt_config_entry_data", + ("mqtt_config_entry_data", "mqtt_config_entry_options"), [ - { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", - } + ( + {mqtt.CONF_BROKER: "mock-broker"}, + { + mqtt.CONF_DISCOVERY_PREFIX: "my_home/homeassistant/register", + }, + ) ], ) async def test_complex_discovery_topic_prefix( @@ -2497,7 +2508,13 @@ async def test_mqtt_integration_discovery_flow_fitering_on_redundant_payload( """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( patch( @@ -2562,7 +2579,13 @@ async def test_mqtt_discovery_flow_starts_once( """Handle birth message.""" birth.set() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data=ENTRY_DEFAULT_BIRTH_MESSAGE) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + options=ENTRY_DEFAULT_BIRTH_MESSAGE, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) with ( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ad9d65894d0..af9975de1ea 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -13,6 +13,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.models import ( @@ -30,7 +31,6 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er, template @@ -45,6 +45,7 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -391,6 +392,25 @@ async def test_service_call_with_ascii_qos_retain_flags( blocking=True, ) assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "" + assert mqtt_mock.async_publish.call_args[0][2] == 2 + assert not mqtt_mock.async_publish.call_args[0][3] + + mqtt_mock.reset_mock() + + # Test service call without payload + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_QOS: "2", + mqtt.ATTR_RETAIN: "no", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] is None assert mqtt_mock.async_publish.call_args[0][2] == 2 assert not mqtt_mock.async_publish.call_args[0][3] @@ -695,7 +715,12 @@ async def test_reload_entry_with_restored_subscriptions( ) -> None: """Test reloading the config entry with with subscriptions restored.""" # Setup the MQTT entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) with patch("homeassistant.config.load_yaml_config_file", return_value={}): @@ -800,7 +825,10 @@ async def test_default_entry_setting_are_applied( # Config entry data is incomplete but valid according the schema entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={"broker": "test-broker", "port": 1234} + domain=mqtt.DOMAIN, + data={"broker": "test-broker", "port": 1234}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) hass.config.components.add(mqtt.DOMAIN) @@ -1545,6 +1573,7 @@ async def test_subscribe_connection_status( setup_with_birth_msg_client_mock: MqttMockPahoClient, ) -> None: """Test connextion status subscription.""" + mqtt_client_mock = setup_with_birth_msg_client_mock mqtt_connected_calls_callback: list[bool] = [] mqtt_connected_calls_async: list[bool] = [] @@ -1562,7 +1591,7 @@ async def test_subscribe_connection_status( assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1576,12 +1605,12 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1591,7 +1620,7 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True @@ -1614,6 +1643,8 @@ async def test_unload_config_entry( entry = MockConfigEntry( domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index dbca09e803c..f8c66a3de1d 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -72,7 +72,7 @@ mqtt: payload_on: "on" payload_off: "off" -config with brightness and color temp +config with brightness and color temp (mired) mqtt: light: @@ -88,6 +88,23 @@ mqtt: payload_on: "on" payload_off: "off" +config with brightness and color temp (Kelvin) + +mqtt: + light: + - name: "Office Light Color Temp" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + brightness_state_topic: "office/rgb1/brightness/status" + brightness_command_topic: "office/rgb1/brightness/set" + brightness_scale: 99 + color_temp_kelvin: true + color_temp_state_topic: "office/rgb1/color_temp/status" + color_temp_command_topic: "office/rgb1/color_temp/set" + qos: 0 + payload_on: "on" + payload_off: "off" + config with brightness and effect mqtt: @@ -305,6 +322,101 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "min_kelvin", "max_kelvin"), + [ + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_mireds": 180, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_mireds": 400, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "max_kelvin": 5555, + }, + ), + ), + light.DEFAULT_MIN_KELVIN, + 5555, + ), + ( + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "min_kelvin": 2500, + }, + ), + ), + 2500, + light.DEFAULT_MAX_KELVIN, + ), + ], +) +async def test_no_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "ON") + state = hass.states.get("light.test") + assert state is not None and state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_MIN_COLOR_TEMP_KELVIN) == min_kelvin + assert state.attributes.get(light.ATTR_MAX_COLOR_TEMP_KELVIN) == max_kelvin + + @pytest.mark.parametrize( "hass_config", [ @@ -431,6 +543,76 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + ("hass_config", "payload", "kelvin"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": False, + } + } + }, + "300", + 3333, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_color_temp/status", + "command_topic": "test_light_color_temp/set", + "brightness_state_topic": "test_light_color_temp/brightness/status", + "brightness_command_topic": "test_light_color_temp/brightness/set", + "color_temp_state_topic": "test_light_color_temp/color_temp/status", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_kelvin": True, + } + } + }, + "3333", + 3333, + ), + ], + ids=["mireds", "kelvin"], +) +async def test_controlling_color_mode_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload: str, + kelvin: int, +) -> None: + """Test the controlling of the color mode state via topic.""" + color_modes = ["color_temp"] + + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get(light.ATTR_COLOR_MODE) is None + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "test_light_color_temp/status", "ON") + async_fire_mqtt_message(hass, "test_light_color_temp/brightness/status", "70") + async_fire_mqtt_message(hass, "test_light_color_temp/color_temp/status", payload) + light_state = hass.states.get("light.test") + assert light_state.attributes.get("brightness") == 70 + assert light_state.attributes["color_temp_kelvin"] == kelvin + assert light_state.attributes.get(light.ATTR_COLOR_MODE) == "color_temp" + assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes + + @pytest.mark.parametrize( "hass_config", [ @@ -1295,25 +1477,47 @@ async def test_sending_mqtt_rgbww_command_with_template( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "color_temp_kelvin": False, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } } - } - } + }, + "10", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (0.5 * value) | round(0) }}", + "color_temp_kelvin": True, + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + }, + "5000", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, payload: str ) -> None: """Test the sending of Color Temp command with template.""" mqtt_mock = await mqtt_mock_entry() @@ -1326,14 +1530,14 @@ async def test_sending_mqtt_color_temp_command_with_template( mqtt_mock.async_publish.assert_has_calls( [ call("test_light_color_temp/set", "on", 0, False), - call("test_light_color_temp/color_temp/set", "10", 0, False), + call("test_light_color_temp/color_temp/set", payload, 0, False), ], any_order=True, ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes["color_temp"] == 100 + assert state.attributes["color_temp_kelvin"] == 10000 @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c127c86de39..bcf9d4bd736 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -14,7 +14,7 @@ mqtt: rgb: true xy: true -Configuration with RGB, brightness, color temp and effect: +Configuration with RGB, brightness, color temp (mireds) and effect: mqtt: light: @@ -24,10 +24,11 @@ mqtt: command_topic: "home/rgb1/set" brightness: true color_temp: true + color_temp_kelvin: false effect: true rgb: true -Configuration with RGB, brightness and color temp: +Configuration with RGB, brightness and color temp (Kelvin): mqtt: light: @@ -38,6 +39,7 @@ mqtt: brightness: true rgb: true color_temp: true + color_temp_kelvin: true Configuration with RGB, brightness: @@ -98,7 +100,6 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads from .test_common import ( @@ -193,172 +194,6 @@ async def test_fail_setup_if_no_command_topic( assert "required key not provided" in caplog.text -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"color_temp": True},)), - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"hs": True},)), - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"rgb": True},)), - help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"xy": True},)), - ], -) -async def test_fail_setup_if_color_mode_deprecated( - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test if setup fails if color mode is combined with deprecated config keys.""" - assert await mqtt_mock_entry() - assert "supported_color_modes must not be combined with any of" in caplog.text - - -@pytest.mark.parametrize( - ("hass_config", "color_modes"), - [ - ( - help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True},)), - ("color_temp",), - ), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"hs": True},)), ("hs",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"rgb": True},)), ("rgb",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"xy": True},)), ("xy",)), - ( - help_custom_config( - light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True, "rgb": True},) - ), - ("color_temp, rgb", "rgb, color_temp"), - ), - ], - ids=["color_temp", "hs", "rgb", "xy", "color_temp, rgb"], -) -async def test_warning_if_color_mode_flags_are_used( - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - color_modes: tuple[str, ...], -) -> None: - """Test warnings deprecated config keys without supported color modes defined.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - assert any( - ( - f"Deprecated flags [{color_modes_case}] used in MQTT JSON light config " - "for handling color mode, please use `supported_color_modes` instead." - in caplog.text - ) - for color_modes_case in color_modes - ) - mock_async_create_issue.assert_called_once() - - -@pytest.mark.parametrize( - ("config", "color_modes"), - [ - ( - help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True},)), - ("color_temp",), - ), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"hs": True},)), ("hs",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"rgb": True},)), ("rgb",)), - (help_custom_config(light.DOMAIN, DEFAULT_CONFIG, ({"xy": True},)), ("xy",)), - ( - help_custom_config( - light.DOMAIN, DEFAULT_CONFIG, ({"color_temp": True, "rgb": True},) - ), - ("color_temp, rgb", "rgb, color_temp"), - ), - ], - ids=["color_temp", "hs", "rgb", "xy", "color_temp, rgb"], -) -async def test_warning_on_discovery_if_color_mode_flags_are_used( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - config: dict[str, Any], - color_modes: tuple[str, ...], -) -> None: - """Test warnings deprecated config keys with discovery.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - - config_payload = json_dumps(config[mqtt.DOMAIN][light.DOMAIN][0]) - async_fire_mqtt_message( - hass, - "homeassistant/light/bla/config", - config_payload, - ) - await hass.async_block_till_done() - assert any( - ( - f"Deprecated flags [{color_modes_case}] used in MQTT JSON light config " - "for handling color mode, please " - "use `supported_color_modes` instead" in caplog.text - ) - for color_modes_case in color_modes - ) - mock_async_create_issue.assert_not_called() - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - light.DOMAIN, - DEFAULT_CONFIG, - ({"color_mode": True, "supported_color_modes": ["color_temp"]},), - ), - ], - ids=["color_temp"], -) -async def test_warning_if_color_mode_option_flag_is_used( - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test warning deprecated color_mode option flag is used.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - assert "Deprecated flag `color_mode` used in MQTT JSON light config" in caplog.text - mock_async_create_issue.assert_called_once() - - -@pytest.mark.parametrize( - "config", - [ - help_custom_config( - light.DOMAIN, - DEFAULT_CONFIG, - ({"color_mode": True, "supported_color_modes": ["color_temp"]},), - ), - ], - ids=["color_temp"], -) -async def test_warning_on_discovery_if_color_mode_option_flag_is_used( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - config: dict[str, Any], -) -> None: - """Test warning deprecated color_mode option flag is used.""" - with patch( - "homeassistant.components.mqtt.light.schema_json.async_create_issue" - ) as mock_async_create_issue: - assert await mqtt_mock_entry() - - config_payload = json_dumps(config[mqtt.DOMAIN][light.DOMAIN][0]) - async_fire_mqtt_message( - hass, - "homeassistant/light/bla/config", - config_payload, - ) - await hass.async_block_till_done() - assert "Deprecated flag `color_mode` used in MQTT JSON light config" in caplog.text - mock_async_create_issue.assert_not_called() - - @pytest.mark.parametrize( ("hass_config", "error"), [ @@ -398,50 +233,6 @@ async def test_fail_setup_if_color_modes_invalid( assert error in caplog.text -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light/set", - "state_topic": "test_light", - "color_mode": True, - "supported_color_modes": "color_temp", - } - } - } - ], -) -async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test setup with single color_mode.""" - await mqtt_mock_entry() - state = hass.states.get("light.test") - assert state.state == STATE_UNKNOWN - - await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=5208 - ) - - async_fire_mqtt_message( - hass, - "test_light", - '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', - ) - color_modes = [light.ColorMode.COLOR_TEMP] - state = hass.states.get("light.test") - assert state.state == STATE_ON - - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 - assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 - assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] - - @pytest.mark.parametrize("hass_config", [COLOR_MODES_CONFIG]) async def test_turn_on_with_unknown_color_mode_optimistic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -516,34 +307,6 @@ async def test_controlling_state_with_unknown_color_mode( assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.COLOR_TEMP -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light_rgb/set", - "rgb": True, - } - } - } - ], -) -async def test_legacy_rgb_light( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test legacy RGB light flags expected features and color modes.""" - await mqtt_mock_entry() - - state = hass.states.get("light.test") - color_modes = [light.ColorMode.HS] - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - - @pytest.mark.parametrize( "hass_config", [ @@ -601,6 +364,17 @@ async def test_no_color_brightness_color_temp_if_no_topics( @pytest.mark.parametrize( "hass_config", [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["brightness"], + } + } + }, { mqtt.DOMAIN: { light.DOMAIN: { @@ -609,21 +383,78 @@ async def test_no_color_brightness_color_temp_if_no_topics( "state_topic": "test_light_rgb", "command_topic": "test_light_rgb/set", "brightness": True, - "color_temp": True, + } + } + }, + ], +) +async def test_brightness_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test brightness only light. + + There are two possible configurations for brightness only light: + 1) Set up "brightness" as supported color mode. + 2) Set "brightness" flag to true. + """ + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.BRIGHTNESS + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness": 50}') + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "color_temp_kelvin": True, "effect": True, - "rgb": True, - "xy": True, - "hs": True, + "supported_color_modes": ["color_temp", "hs"], "qos": "0", } } } ], ) -async def test_controlling_state_via_topic( +async def test_controlling_state_color_temp_kelvin( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: - """Test the controlling of the state via topic.""" + """Test the controlling of the state via topic in Kelvin mode.""" await mqtt_mock_entry() state = hass.states.get("light.test") @@ -631,9 +462,11 @@ async def test_controlling_state_via_topic( color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None assert state.attributes.get("color_temp_kelvin") is None @@ -647,7 +480,8 @@ async def test_controlling_state_via_topic( hass, "test_light_rgb", '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' + '"color":{"h": 44.098, "s": 2.43},' + '"color_mode": "hs",' '"brightness":255,' '"color_temp":155,' '"effect":"colorloop"}', @@ -655,12 +489,12 @@ async def test_controlling_state_via_topic( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (255, 253, 249) assert state.attributes.get("brightness") == 255 assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority assert state.attributes.get("effect") == "colorloop" - assert state.attributes.get("xy_color") == (0.323, 0.329) - assert state.attributes.get("hs_color") == (0.0, 0.0) + assert state.attributes.get("xy_color") == (0.328, 0.333) + assert state.attributes.get("hs_color") == (44.098, 2.43) # Turn on the light async_fire_mqtt_message( @@ -669,7 +503,8 @@ async def test_controlling_state_via_topic( '{"state":"ON",' '"brightness":255,' '"color":null,' - '"color_temp":155,' + '"color_mode":"color_temp",' + '"color_temp":6451,' # Kelvin '"effect":"colorloop"}', ) @@ -686,107 +521,6 @@ async def test_controlling_state_via_topic( assert state.attributes.get("xy_color") == (0.328, 0.333) # temp converted to color assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color - # Turn the light off - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') - - state = hass.states.get("light.test") - assert state.state == STATE_OFF - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness":100}') - - light_state = hass.states.get("light.test") - - assert light_state.attributes["brightness"] == 100 - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color":{"r":125,"g":125,"b":125}}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("rgb_color") == (255, 255, 255) - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color":{"x":0.135,"y":0.135}}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("xy_color") == (0.141, 0.141) - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color":{"h":180,"s":50}}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("hs_color") == (180.0, 50.0) - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color":null}') - - light_state = hass.states.get("light.test") - assert "hs_color" in light_state.attributes # Color temp approximation - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":155}') - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("color_temp_kelvin") == 6451 # 155 mired - - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":null}') - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("color_temp_kelvin") is None - - async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "effect":"colorloop"}' - ) - - light_state = hass.states.get("light.test") - assert light_state.attributes.get("effect") == "colorloop" - - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' - '"brightness":128,' - '"color_temp":155,' - '"effect":"colorloop"}', - ) - light_state = hass.states.get("light.test") - assert light_state.state == STATE_ON - assert light_state.attributes.get("brightness") == 128 - - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"OFF","brightness":0}', - ) - light_state = hass.states.get("light.test") - assert light_state.state == STATE_OFF - assert light_state.attributes.get("brightness") is None - - # Simulate the lights color temp has been changed - # while it was switched off - async_fire_mqtt_message( - hass, - "test_light_rgb", - '{"state":"OFF","color_temp":201}', - ) - light_state = hass.states.get("light.test") - assert light_state.state == STATE_OFF - # Color temp attribute is not exposed while the lamp is off - assert light_state.attributes.get("color_temp_kelvin") is None - - # test previous zero brightness received was ignored and brightness is restored - # see if the latest color_temp value received is restored - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON"}') - light_state = hass.states.get("light.test") - assert light_state.attributes.get("brightness") == 128 - assert light_state.attributes.get("color_temp_kelvin") == 4975 # 201 mired - - # A `0` brightness value is ignored when a light is turned on - async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON","brightness":0}') - light_state = hass.states.get("light.test") - assert light_state.attributes.get("brightness") == 128 - @pytest.mark.parametrize( "hass_config", @@ -796,7 +530,7 @@ async def test_controlling_state_via_topic( ) ], ) -async def test_controlling_state_via_topic2( +async def test_controlling_state_via_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, @@ -857,6 +591,11 @@ async def test_controlling_state_via_topic2( state = hass.states.get("light.test") assert state.attributes["brightness"] == 100 + # Zero brightness value is ignored + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness":0}') + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 100 + # RGB color async_fire_mqtt_message( hass, @@ -959,242 +698,6 @@ async def test_controlling_state_via_topic2( { mqtt.DOMAIN: { light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light_rgb/set", - "state_topic": "test_light_rgb/set", - "rgb": True, - "color_temp": True, - "brightness": True, - } - } - } - ], -) -async def test_controlling_the_state_with_legacy_color_handling( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test state updates for lights with a legacy color handling.""" - supported_color_modes = ["color_temp", "hs"] - await mqtt_mock_entry() - - state = hass.states.get("light.test") - assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - assert state.attributes.get("brightness") is None - assert state.attributes.get("color_mode") is None - assert state.attributes.get("color_temp_kelvin") is None - assert state.attributes.get("effect") is None - assert state.attributes.get("hs_color") is None - assert state.attributes.get("rgb_color") is None - assert state.attributes.get("rgbw_color") is None - assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("supported_color_modes") == supported_color_modes - assert state.attributes.get("xy_color") is None - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - for _ in range(2): - # Returned state after the light was turned on - # Receiving legacy color mode: rgb. - async_fire_mqtt_message( - hass, - "test_light_rgb/set", - '{ "state": "ON", "brightness": 255, "level": 100, "hue": 16,' - '"saturation": 100, "color": { "r": 255, "g": 67, "b": 0 }, ' - '"bulb_mode": "color", "color_mode": "rgb" }', - ) - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_mode") == "hs" - assert state.attributes.get("color_temp_kelvin") is None - assert state.attributes.get("effect") is None - assert state.attributes.get("hs_color") == (15.765, 100.0) - assert state.attributes.get("rgb_color") == (255, 67, 0) - assert state.attributes.get("rgbw_color") is None - assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("xy_color") == (0.674, 0.322) - - # Returned state after the lights color mode was changed - # Receiving legacy color mode: color_temp - async_fire_mqtt_message( - hass, - "test_light_rgb/set", - '{ "state": "ON", "brightness": 255, "level": 100, ' - '"kelvin": 92, "color_temp": 353, "bulb_mode": "white", ' - '"color_mode": "color_temp" }', - ) - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_mode") == "color_temp" - assert state.attributes.get("color_temp_kelvin") == 2832 - assert state.attributes.get("effect") is None - assert state.attributes.get("hs_color") == (28.125, 61.661) - assert state.attributes.get("rgb_color") == (255, 171, 98) - assert state.attributes.get("rgbw_color") is None - assert state.attributes.get("rgbww_color") is None - assert state.attributes.get("xy_color") == (0.512, 0.385) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_light_rgb/set", - "brightness": True, - "color_temp": True, - "effect": True, - "hs": True, - "rgb": True, - "xy": True, - "qos": 2, - } - } - } - ], -) -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp_kelvin": 10000, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - mqtt_mock = await mqtt_mock_entry() - - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("brightness") == 95 - assert state.attributes.get("hs_color") == (100, 100) - assert state.attributes.get("effect") == "random" - assert state.attributes.get("color_temp_kelvin") is None # hs_color has priority - color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] - assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features - assert state.attributes.get(ATTR_ASSUMED_STATE) - - await common.async_turn_on(hass, "light.test") - - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state":"ON"}', 2, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - - await common.async_turn_on(hass, "light.test", color_temp_kelvin=11111) - - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator('{"state": "ON", "color_temp": 90}'), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP - assert state.attributes.get("color_temp_kelvin") == 11111 - - await common.async_turn_off(hass, "light.test") - - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state":"OFF"}', 2, False - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_OFF - - mqtt_mock.reset_mock() - await common.async_turn_on( - hass, "light.test", brightness=50, xy_color=(0.123, 0.123) - ) - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator( - '{"state": "ON", "color": {"r": 0, "g": 124, "b": 255,' - ' "x": 0.14, "y": 0.133, "h": 210.824, "s": 100.0},' - ' "brightness": 50}' - ), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.attributes.get("color_mode") == light.ColorMode.HS - assert state.attributes["brightness"] == 50 - assert state.attributes["hs_color"] == (210.824, 100.0) - assert state.attributes["rgb_color"] == (0, 124, 255) - assert state.attributes["xy_color"] == (0.14, 0.133) - - await common.async_turn_on(hass, "light.test", brightness=50, hs_color=(359, 78)) - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator( - '{"state": "ON", "color": {"r": 255, "g": 56, "b": 59,' - ' "x": 0.654, "y": 0.301, "h": 359.0, "s": 78.0},' - ' "brightness": 50}' - ), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("color_mode") == light.ColorMode.HS - assert state.attributes["brightness"] == 50 - assert state.attributes["hs_color"] == (359.0, 78.0) - assert state.attributes["rgb_color"] == (255, 56, 59) - assert state.attributes["xy_color"] == (0.654, 0.301) - - await common.async_turn_on(hass, "light.test", rgb_color=(255, 128, 0)) - mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", - JsonValidator( - '{"state": "ON", "color": {"r": 255, "g": 128, "b": 0,' - ' "x": 0.611, "y": 0.375, "h": 30.118, "s": 100.0}}' - ), - 2, - False, - ) - mqtt_mock.async_publish.reset_mock() - state = hass.states.get("light.test") - assert state.state == STATE_ON - assert state.attributes.get("color_mode") == light.ColorMode.HS - assert state.attributes["brightness"] == 50 - assert state.attributes["hs_color"] == (30.118, 100) - assert state.attributes["rgb_color"] == (255, 128, 0) - assert state.attributes["xy_color"] == (0.611, 0.375) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, "command_topic": "test_light_rgb/set", "effect": True, "name": "test", @@ -1214,7 +717,7 @@ async def test_sending_mqtt_commands_and_optimistic( } ], ) -async def test_sending_mqtt_commands_and_optimistic2( +async def test_sending_mqtt_commands_and_optimistic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode for a light supporting color mode.""" @@ -1436,8 +939,7 @@ async def test_sending_mqtt_commands_and_optimistic2( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "brightness": True, - "hs": True, + "supported_color_modes": ["hs"], } } } @@ -1499,7 +1001,7 @@ async def test_sending_hs_color( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "rgb": True, + "supported_color_modes": ["rgb"], } } } @@ -1554,7 +1056,6 @@ async def test_sending_rgb_color_no_brightness( { mqtt.DOMAIN: { light.DOMAIN: { - "color_mode": True, "command_topic": "test_light_rgb/set", "name": "test", "schema": "json", @@ -1637,8 +1138,8 @@ async def test_sending_rgb_color_no_brightness2( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", + "supported_color_modes": ["rgb"], "brightness": True, - "rgb": True, } } } @@ -1705,9 +1206,9 @@ async def test_sending_rgb_color_with_brightness( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", + "supported_color_modes": ["rgb"], "brightness": True, "brightness_scale": 100, - "rgb": True, } } } @@ -1775,9 +1276,7 @@ async def test_sending_rgb_color_with_scaled_brightness( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "brightness": True, "brightness_scale": 100, - "color_mode": True, "supported_color_modes": ["hs", "white"], "white_scale": 50, } @@ -1822,8 +1321,7 @@ async def test_sending_scaled_white( "schema": "json", "name": "test", "command_topic": "test_light_rgb/set", - "brightness": True, - "xy": True, + "supported_color_modes": ["xy"], } } } @@ -1849,7 +1347,7 @@ async def test_sending_xy_color( call( "test_light_rgb/set", JsonValidator( - '{"state": "ON", "color": {"x": 0.14, "y": 0.133},' + '{"state": "ON", "color": {"x": 0.123, "y": 0.123},' ' "brightness": 50}' ), 0, @@ -2066,7 +1564,7 @@ async def test_transition( "name": "test", "state_topic": "test_light_bright_scale", "command_topic": "test_light_bright_scale/set", - "brightness": True, + "supported_color_modes": ["brightness"], "brightness_scale": 99, } } @@ -2131,7 +1629,6 @@ async def test_brightness_scale( "command_topic": "test_light_bright_scale/set", "brightness": True, "brightness_scale": 99, - "color_mode": True, "supported_color_modes": ["hs", "white"], "white_scale": 50, } @@ -2191,8 +1688,7 @@ async def test_white_scale( "state_topic": "test_light_rgb", "command_topic": "test_light_rgb/set", "brightness": True, - "color_temp": True, - "rgb": True, + "supported_color_modes": ["hs", "color_temp"], "qos": "0", } } @@ -2225,62 +1721,64 @@ async def test_invalid_values( '{"state":"ON",' '"color":{"r":255,"g":255,"b":255},' '"brightness": 255,' + '"color_mode": "color_temp",' '"color_temp": 100,' '"effect": "rainbow"}', ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + # Color converttrd from color_temp to rgb + assert state.attributes.get("rgb_color") == (202, 218, 255) assert state.attributes.get("brightness") == 255 - assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("color_temp_kelvin") == 10000 # Empty color value async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{}}', + '{"state":"ON", "color":{}, "color_mode": "rgb"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad HS color values async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{"h":"bad","s":"val"}}', + '{"state":"ON", "color":{"h":"bad","s":"val"}, "color_mode": "hs"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad RGB color values async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{"r":"bad","g":"val","b":"test"}}', + '{"state":"ON", "color":{"r":"bad","g":"val","b":"test"}, "color_mode": "rgb"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad XY color values async_fire_mqtt_message( hass, "test_light_rgb", - '{"state":"ON", "color":{"x":"bad","y":"val"}}', + '{"state":"ON", "color":{"x":"bad","y":"val"}, "color_mode": "xy"}', ) # Color should not have changed state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("rgb_color") == (255, 255, 255) + assert state.attributes.get("rgb_color") == (202, 218, 255) # Bad brightness values async_fire_mqtt_message( @@ -2294,7 +1792,9 @@ async def test_invalid_values( # Unset color and set a valid color temperature async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color": null, "color_temp": 100}' + hass, + "test_light_rgb", + '{"state":"ON", "color": null, "color_temp": 100, "color_mode": "color_temp"}', ) state = hass.states.get("light.test") assert state.state == STATE_ON @@ -2302,11 +1802,14 @@ async def test_invalid_values( # Bad color temperature async_fire_mqtt_message( - hass, "test_light_rgb", '{"state":"ON", "color_temp": "badValue"}' + hass, + "test_light_rgb", + '{"state":"ON", "color_temp": "badValue", "color_mode": "color_temp"}', ) assert ( - "Invalid color temp value 'badValue' received for entity light.test" - in caplog.text + "Invalid or incomplete color value '{'state': 'ON', 'color_temp': " + "'badValue', 'color_mode': 'color_temp'}' " + "received for entity light.test" in caplog.text ) # Color temperature should not have changed @@ -2591,30 +2094,82 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_kelvin", "max_kelvin"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_mireds": 370, # 2702 Kelvin + } } - } - } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_mireds": 150, # 6666 Kelvin + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "min_kelvin": 2702, + } + } + }, + 2702, + light.DEFAULT_MAX_KELVIN, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "supported_color_modes": ["color_temp"], + "max_kelvin": 6666, + } + } + }, + light.DEFAULT_MIN_KELVIN, + 6666, + ), ], ) -async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +async def test_min_max_kelvin( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_kelvin: int, + max_kelvin: int, ) -> None: - """Test setting min_mireds and max_mireds.""" + """Test setting min_color_temp_kelvin and max_color_temp_kelvin.""" await mqtt_mock_entry() state = hass.states.get("light.test") - assert state.attributes.get("min_mireds") == 153 - assert state.attributes.get("max_mireds") == 370 + assert state.attributes.get("min_color_temp_kelvin") == min_kelvin + assert state.attributes.get("max_color_temp_kelvin") == max_kelvin @pytest.mark.parametrize( @@ -2751,7 +2306,6 @@ async def test_setup_manual_entity_from_yaml( DEFAULT_CONFIG, ( { - "color_mode": True, "effect": True, "supported_color_modes": [ "color_temp", diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 4d2b93ff159..568d86f8bd9 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -179,25 +179,50 @@ async def test_rgb_light( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light/set", - "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", - "command_off_template": "off", - "brightness_template": "{{ value.split(',')[1] }}", - "color_temp_template": "{{ value.split(',')[2] }}", + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } } - } - } + }, + 5208, + "192", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light/set", + "command_on_template": "on,{{ brightness|d }},{{ color_temp|d }}", + "command_off_template": "off", + "brightness_template": "{{ value.split(',')[1] }}", + "color_temp_template": "{{ value.split(',')[2] }}", + } + } + }, + 5208, + "5208", + ), ], + ids=["mireds", "kelvin"], ) async def test_single_color_mode( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the color mode when we only have one supported color_mode.""" await mqtt_mock_entry() @@ -206,15 +231,15 @@ async def test_single_color_mode( assert state.state == STATE_UNKNOWN await common.async_turn_on( - hass, "light.test", brightness=50, color_temp_kelvin=5208 + hass, "light.test", brightness=50, color_temp_kelvin=kelvin ) - async_fire_mqtt_message(hass, "test_light", "on,50,192") + async_fire_mqtt_message(hass, "test_light", f"on,50,{payload}") color_modes = [light.ColorMode.COLOR_TEMP] state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == 5208 + assert state.attributes.get(light.ATTR_COLOR_TEMP_KELVIN) == kelvin assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] @@ -392,39 +417,80 @@ async def test_state_brightness_color_effect_temp_change_via_topic( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "kelvin", "payload"), [ - { - mqtt.DOMAIN: { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_light_rgb/set", - "command_on_template": "on," - "{{ brightness|d }}," - "{{ color_temp|d }}," - "{{ red|d }}-" - "{{ green|d }}-" - "{{ blue|d }}," - "{{ hue|d }}-" - "{{ sat|d }}", - "command_off_template": "off", - "effect_list": ["colorloop", "random"], - "optimistic": True, - "state_template": '{{ value.split(",")[0] }}', - "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].split("-")[0] }}', - "green_template": '{{ value.split(",")[3].split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].split("-")[2] }}', - "effect_template": '{{ value.split(",")[4] }}', - "qos": 2, + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": False, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } } - } - } + }, + 14285, + "on,,70,--,-", + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_light_rgb/set", + "command_on_template": "on," + "{{ brightness|d }}," + "{{ color_temp|d }}," + "{{ red|d }}-" + "{{ green|d }}-" + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", + "command_off_template": "off", + "effect_list": ["colorloop", "random"], + "optimistic": True, + "state_template": '{{ value.split(",")[0] }}', + "color_temp_kelvin": True, + "color_temp_template": '{{ value.split(",")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', + "effect_template": '{{ value.split(",")[4] }}', + "qos": 2, + } + }, + }, + 14285, + "on,,14285,--,-", + ), ], + ids=["mireds", "kelvin"], ) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + kelvin: int, + payload: str, ) -> None: """Test the sending of command in optimistic mode.""" fake_state = State( @@ -465,14 +531,15 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_ON # Set color_temp - await common.async_turn_on(hass, "light.test", color_temp_kelvin=14285) + await common.async_turn_on(hass, "light.test", color_temp_kelvin=kelvin) + # Assert mireds or Kelvin as payload mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,70,--,-", 2, False + "test_light_rgb/set", payload, 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("color_temp_kelvin") == 14285 + assert state.attributes.get("color_temp_kelvin") == kelvin # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 5b7984cad62..d65f1a4d661 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -313,7 +313,12 @@ async def test_default_entity_and_device_name( hass.set_state(CoreState.starting) await hass.async_block_till_done() - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) + entry = MockConfigEntry( + domain=mqtt.DOMAIN, + data={mqtt.CONF_BROKER: "mock-broker"}, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 48aaa11f672..7bdd39e81a7 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -29,6 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .test_common import ( help_custom_config, @@ -157,6 +158,101 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 15, + "max": 28, + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + } + } + } + ], +) +async def test_native_value_validation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test state validation and native value conversion.""" + mqtt_mock = await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/state_number", "23.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + + # Test out of range validation + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 15 + assert state.attributes.get(ATTR_MAX) == 28 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.CELSIUS.value + ) + assert state.state == "23.5" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + # Check if validation still works when changing unit system + hass.config.units = US_CUSTOMARY_SYSTEM + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test/state_number", "24.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + + # Test out of range validation again + async_fire_mqtt_message(hass, "test/state_number", "29.5") + state = hass.states.get("number.test_number") + assert state is not None + assert state.attributes.get(ATTR_MIN) == 59.0 + assert state.attributes.get(ATTR_MAX) == 82.4 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT.value + ) + assert state.state == "76.1" + assert ( + "Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text + ) + caplog.clear() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 7f418864872..9226b03a7d2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, @@ -1409,6 +1409,7 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.usefixtures("mock_temp_dir") @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 37bf6982b7a..f751096bca2 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable from datetime import timedelta from pathlib import Path -from random import getrandbits import shutil import tempfile from unittest.mock import MagicMock, patch @@ -53,7 +52,7 @@ async def test_canceling_debouncer_on_shutdown( assert not mock_debouncer.is_set() mqtt_client_mock.subscribe.assert_not_called() - # Note thet the broker connection will not be disconnected gracefully + # Note that the broker connection will not be disconnected gracefully await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) await asyncio.sleep(0) @@ -199,7 +198,6 @@ async def test_reading_non_exitisting_certificate_file() -> None: ) -@pytest.mark.parametrize("temp_dir_prefix", "unknown") async def test_return_default_get_file_path( hass: HomeAssistant, mock_temp_dir: str ) -> None: @@ -211,12 +209,8 @@ async def test_return_default_get_file_path( and mqtt.util.get_file_path("some_option", "mydefault") == "mydefault" ) - with patch( - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - f"home-assistant-mqtt-other-{getrandbits(10):03x}", - ) as temp_dir_name: - tempdir = Path(tempfile.gettempdir()) / temp_dir_name - assert await hass.async_add_executor_job(_get_file_path, tempdir) + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + assert await hass.async_add_executor_job(_get_file_path, temp_dir) async def test_waiting_for_client_not_loaded( @@ -231,6 +225,8 @@ async def test_waiting_for_client_not_loaded( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -286,6 +282,8 @@ async def test_waiting_for_client_entry_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -314,6 +312,8 @@ async def test_waiting_for_client_setup_fails( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -341,6 +341,8 @@ async def test_waiting_for_client_timeout( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) @@ -360,6 +362,8 @@ async def test_waiting_for_client_with_disabled_entry( domain=mqtt.DOMAIN, data={"broker": "test-broker"}, state=ConfigEntryState.NOT_LOADED, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, ) entry.add_to_hass(hass) diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index b6c1940b149..cbf02299b09 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -5,12 +5,12 @@ from unittest.mock import ANY, patch import pytest -import homeassistant.components.mqtt_eventstream as eventstream +from homeassistant.components import mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( async_fire_mqtt_message, diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index 9798477945c..63c3ea14e44 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import ANY, call import pytest -import homeassistant.components.mqtt_statestream as statestream +from homeassistant.components import mqtt_statestream as statestream from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 7c0f9df751a..6d7ef927c6e 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,11 +2,21 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType -from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, +) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -60,6 +70,10 @@ async def setup_integration_from_fixtures( music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) library_radios = create_library_radios_from_fixture() music.get_library_radios = AsyncMock(return_value=library_radios) + library_audiobooks = create_library_audiobooks_from_fixture() + music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks) + library_podcasts = create_library_podcasts_from_fixture() + music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() config_entry.add_to_hass(hass) @@ -130,19 +144,58 @@ def create_library_radios_from_fixture() -> list[Radio]: return [Radio.from_dict(radio_data) for radio_data in fixture_data] +def create_library_audiobooks_from_fixture() -> list[Audiobook]: + """Create MA Audiobooks from fixture.""" + fixture_data = load_and_parse_fixture("library_audiobooks") + return [Audiobook.from_dict(radio_data) for radio_data in fixture_data] + + +def create_library_podcasts_from_fixture() -> list[Podcast]: + """Create MA Podcasts from fixture.""" + fixture_data = load_and_parse_fixture("library_podcasts") + return [Podcast.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, event: EventType = EventType.PLAYER_UPDATED, + object_id: str | None = None, data: Any = None, ) -> None: """Trigger a subscription callback.""" # trigger callback on all subscribers - for sub in client.subscribe_events.call_args_list: - callback = sub.kwargs["callback"] - event_filter = sub.kwargs.get("event_filter") - if event_filter in (None, event): - callback(event, data) + for sub in client.subscribe.call_args_list: + cb_func = sub.kwargs.get("cb_func", sub.args[0]) + event_filter = sub.kwargs.get( + "event_filter", sub.args[1] if len(sub.args) > 1 else None + ) + id_filter = sub.kwargs.get( + "id_filter", sub.args[2] if len(sub.args) > 2 else None + ) + if not ( + event_filter is None + or event == event_filter + or (isinstance(event_filter, list) and event in event_filter) + ): + continue + if not ( + id_filter is None + or object_id == id_filter + or (isinstance(id_filter, list) and object_id in id_filter) + ): + continue + + event = MassEvent( + event=event, + object_id=object_id, + data=data, + ) + if asyncio.iscoroutinefunction(cb_func): + await cb_func(event) + else: + cb_func(event) + await hass.async_block_till_done() diff --git a/tests/components/music_assistant/fixtures/library_audiobooks.json b/tests/components/music_assistant/fixtures/library_audiobooks.json new file mode 100644 index 00000000000..1994ee68e14 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_audiobooks.json @@ -0,0 +1,489 @@ +{ + "library_audiobooks": [ + { + "item_id": "1", + "provider": "library", + "name": "Test Audiobook", + "version": "", + "sort_name": "test audiobook", + "uri": "library://audiobook/1", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "test-audiobook.mp3", + "provider_domain": "filesystem_smb", + "provider_instance": "filesystem_smb--7Kf8QySu", + "available": true, + "audio_format": { + "content_type": "mp3", + "codec_type": "?", + "sample_rate": 48000, + "bit_depth": 16, + "channels": 1, + "output_format_str": "mp3", + "bit_rate": 90304 + }, + "url": null, + "details": "1738502411" + } + ], + "metadata": { + "description": "Cover (front)", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "test-audiobook.mp3", + "provider": "filesystem_smb--7Kf8QySu", + "remotely_accessible": false + } + ], + "genres": [], + "mood": null, + "style": null, + "copyright": null, + "lyrics": "", + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": null, + "authors": ["TestWriter"], + "narrators": [], + "duration": 9, + "fully_played": true, + "resume_position_ms": 9000 + }, + { + "item_id": "11", + "provider": "library", + "name": "Test Audiobook 0", + "version": "", + "sort_name": "test audiobook 0", + "uri": "library://audiobook/11", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "12", + "provider": "library", + "name": "Test Audiobook 1", + "version": "", + "sort_name": "test audiobook 1", + "uri": "library://audiobook/12", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "13", + "provider": "library", + "name": "Test Audiobook 2", + "version": "", + "sort_name": "test audiobook 2", + "uri": "library://audiobook/13", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "14", + "provider": "library", + "name": "Test Audiobook 3", + "version": "", + "sort_name": "test audiobook 3", + "uri": "library://audiobook/14", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "15", + "provider": "library", + "name": "Test Audiobook 4", + "version": "", + "sort_name": "test audiobook 4", + "uri": "library://audiobook/15", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_podcasts.json b/tests/components/music_assistant/fixtures/library_podcasts.json new file mode 100644 index 00000000000..2c6a9c62f65 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_podcasts.json @@ -0,0 +1,309 @@ +{ + "library_podcasts": [ + { + "item_id": "6", + "provider": "library", + "name": "Test Podcast 0", + "version": "", + "sort_name": "test podcast 0", + "uri": "library://podcast/6", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "7", + "provider": "library", + "name": "Test Podcast 1", + "version": "", + "sort_name": "test podcast 1", + "uri": "library://podcast/7", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "8", + "provider": "library", + "name": "Test Podcast 2", + "version": "", + "sort_name": "test podcast 2", + "uri": "library://podcast/8", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "9", + "provider": "library", + "name": "Test Podcast 3", + "version": "", + "sort_name": "test podcast 3", + "uri": "library://podcast/9", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "10", + "provider": "library", + "name": "Test Podcast 4", + "version": "", + "sort_name": "test podcast 4", + "uri": "library://podcast/10", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr index 6c30ffc512c..32c8776c953 100644 --- a/tests/components/music_assistant/snapshots/test_actions.ambr +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -1,5 +1,195 @@ # serializer version: 1 -# name: test_get_library_action +# name: test_get_library_action[album] + dict({ + 'items': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[artist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'W O L F C L U B', + 'uri': 'library://artist/127', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[audiobook] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook', + 'uri': 'library://audiobook/1', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 0', + 'uri': 'library://audiobook/11', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 1', + 'uri': 'library://audiobook/12', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 2', + 'uri': 'library://audiobook/13', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 3', + 'uri': 'library://audiobook/14', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 4', + 'uri': 'library://audiobook/15', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[playlist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '1970s Rock Hits', + 'uri': 'library://playlist/40', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[podcast] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 0', + 'uri': 'library://podcast/6', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 1', + 'uri': 'library://podcast/7', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 2', + 'uri': 'library://podcast/8', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 3', + 'uri': 'library://podcast/9', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 4', + 'uri': 'library://podcast/10', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[radio] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'fm4 | ORF | HQ', + 'uri': 'library://radio/1', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[track] dict({ 'items': list([ dict({ @@ -192,8 +382,12 @@ ]), 'artists': list([ ]), + 'audiobooks': list([ + ]), 'playlists': list([ ]), + 'podcasts': list([ + ]), 'radio': list([ ]), 'tracks': list([ diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index 6c5389dbd6a..a07bde4b29d 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +73,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -142,6 +144,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index 4d3917091c1..ba8b1acdeac 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults +import pytest from syrupy import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( @@ -47,9 +48,22 @@ async def test_search_action( assert response == snapshot +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) async def test_get_library_action( hass: HomeAssistant, music_assistant_client: MagicMock, + media_type: str, snapshot: SnapshotAssertion, ) -> None: """Test music assistant get_library action.""" @@ -60,7 +74,7 @@ async def test_get_library_action( { ATTR_CONFIG_ENTRY_ID: entry.entry_id, ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: "track", + ATTR_MEDIA_TYPE: media_type, }, blocking=True, return_response=True, diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py index c700060889c..89cda62961b 100644 --- a/tests/components/music_assistant/test_config_flow.py +++ b/tests/components/music_assistant/test_config_flow.py @@ -15,10 +15,10 @@ import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 25dfcd22c72..44317d4977a 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock, call -from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + EventType, + MediaType, + PlayerFeature, + QueueOption, +) from music_assistant_models.media_items import Track import pytest from syrupy import SnapshotAssertion @@ -20,6 +26,7 @@ from homeassistant.components.media_player import ( SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_UNJOIN, + MediaPlayerEntityFeature, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -59,7 +66,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) from tests.common import AsyncMock @@ -607,3 +618,104 @@ async def test_media_player_get_queue_action( # no call is made, this info comes from the cached queue data assert music_assistant_client.send_command.call_count == 0 assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) + + +async def test_media_player_supported_features( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test if media_player entity supported features are cortrectly (re)mapped.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + expected_features = ( + MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + ) + assert state.attributes["supported_features"] == expected_features + # remove power control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].power_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.TURN_ON + expected_features &= ~MediaPlayerEntityFeature.TURN_OFF + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove volume control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].volume_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET + expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove mute control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].mute_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove pause capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.PAUSE + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.PAUSE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove grouping capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.SET_MEMBERS + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.GROUPING + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features diff --git a/tests/components/myq/test_init.py b/tests/components/myq/test_init.py index 24e03f56075..61ec0273f76 100644 --- a/tests/components/myq/test_init.py +++ b/tests/components/myq/test_init.py @@ -1,7 +1,11 @@ """Tests for the MyQ Connected Services integration.""" from homeassistant.components.myq import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_myq_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_myq_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py index 8ee62996f92..aee6657b270 100644 --- a/tests/components/mystrom/__init__.py +++ b/tests/components/mystrom/__init__.py @@ -179,4 +179,4 @@ class MyStromSwitchMock(MyStromDeviceMock): """Return the URI.""" if not self._requested_state: return None - return f"http://{self._state["ip"]}" + return f"http://{self._state['ip']}" diff --git a/tests/components/myuplink/fixtures/device-alfred.json b/tests/components/myuplink/fixtures/device-alfred.json new file mode 100644 index 00000000000..ca6f91459f6 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-alfred.json @@ -0,0 +1,40 @@ +{ + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7A", + "desiredFwVersion": "9682R7A" + }, + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-batman.json b/tests/components/myuplink/fixtures/device-batman.json new file mode 100644 index 00000000000..f7c079be5dd --- /dev/null +++ b/tests/components/myuplink/fixtures/device-batman.json @@ -0,0 +1,40 @@ +{ + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7B", + "desiredFwVersion": "9682R7B" + }, + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-robin.json b/tests/components/myuplink/fixtures/device-robin.json new file mode 100644 index 00000000000..3155d6e3f70 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-robin.json @@ -0,0 +1,40 @@ +{ + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7C", + "desiredFwVersion": "9682R7C" + }, + "product": { + "serialNumber": "10003", + "name": "SMO 20" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device_points_nibe_f730.json b/tests/components/myuplink/fixtures/device_points_nibe_f730.json index 0a61ab05f21..795a89e7e13 100644 --- a/tests/components/myuplink/fixtures/device_points_nibe_f730.json +++ b/tests/components/myuplink/fixtures/device_points_nibe_f730.json @@ -822,7 +822,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/fixtures/systems-multi.json b/tests/components/myuplink/fixtures/systems-multi.json new file mode 100644 index 00000000000..a587900d23c --- /dev/null +++ b/tests/components/myuplink/fixtures/systems-multi.json @@ -0,0 +1,61 @@ +{ + "page": 1, + "itemsPerPage": 10, + "numItems": 3, + "systems": [ + { + "systemId": "123456-7890-1234", + "name": "Gotham City", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7A", + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Batcave", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7B", + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Duckburg", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7C", + "product": { + "serialNumber": "10003", + "name": "SM0 20" + } + } + ] + } + ] +} diff --git a/tests/components/myuplink/snapshots/test_binary_sensor.ambr b/tests/components/myuplink/snapshots/test_binary_sensor.ambr index 755cae3c623..478c5a55b80 100644 --- a/tests/components/myuplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -285,6 +291,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_diagnostics.ambr b/tests/components/myuplink/snapshots/test_diagnostics.ambr index 6fe6becff11..521823e282d 100644 --- a/tests/components/myuplink/snapshots/test_diagnostics.ambr +++ b/tests/components/myuplink/snapshots/test_diagnostics.ambr @@ -883,7 +883,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -2045,7 +2045,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr new file mode 100644 index 00000000000..14be11c36ec --- /dev/null +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -0,0 +1,100 @@ +# serializer version: 1 +# name: test_device_info[alfred-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Jäspi', + 'model': 'Tehowatti Air', + 'model_id': None, + 'name': 'Gotham City', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10001', + 'suggested_area': None, + 'sw_version': '9682R7A', + 'via_device_id': None, + }) +# --- +# name: test_device_info[batman-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'F730', + 'model_id': None, + 'name': 'Batcave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10002', + 'suggested_area': None, + 'sw_version': '9682R7B', + 'via_device_id': None, + }) +# --- +# name: test_device_info[robin-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'SMO 20', + 'model_id': None, + 'name': 'Duckburg', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10003', + 'suggested_area': None, + 'sw_version': '9682R7C', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/myuplink/snapshots/test_number.ambr b/tests/components/myuplink/snapshots/test_number.ambr index c47d3c60295..f2c89663879 100644 --- a/tests/components/myuplink/snapshots/test_number.ambr +++ b/tests/components/myuplink/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +125,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -233,6 +237,7 @@ 'step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +293,7 @@ 'step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -343,6 +349,7 @@ 'step': 10.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -399,6 +406,7 @@ 'step': 10.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_select.ambr b/tests/components/myuplink/snapshots/test_select.ambr index eff06bc7f2d..032fd2ef455 100644 --- a/tests/components/myuplink/snapshots/test_select.ambr +++ b/tests/components/myuplink/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +73,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr index a5469dc9a77..f9249651208 100644 --- a/tests/components/myuplink/snapshots/test_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -720,6 +734,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +781,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -812,6 +828,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -859,6 +876,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -908,6 +926,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -959,6 +978,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1008,6 +1028,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1055,6 +1076,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1104,6 +1126,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1158,6 +1181,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1210,6 +1234,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1257,6 +1282,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1304,6 +1330,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1351,6 +1378,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1398,6 +1426,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1445,6 +1474,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1494,6 +1524,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1545,6 +1576,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1596,6 +1628,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1647,6 +1680,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1698,6 +1732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1749,6 +1784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1800,6 +1836,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1851,6 +1888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1902,6 +1940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1953,6 +1992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2002,6 +2042,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2049,6 +2090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2098,6 +2140,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2149,6 +2192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2200,6 +2244,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2251,6 +2296,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2302,6 +2348,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2353,6 +2400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2404,6 +2452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2455,6 +2504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2514,6 +2564,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2580,6 +2631,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2636,6 +2688,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2682,6 +2735,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2730,6 +2784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2781,6 +2836,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2832,6 +2888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2883,6 +2940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2934,6 +2992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2985,6 +3044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3036,6 +3096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3087,6 +3148,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3138,6 +3200,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3189,6 +3252,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3240,6 +3304,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3291,6 +3356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3350,6 +3416,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3396,7 +3463,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_2-entry] @@ -3416,6 +3483,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3462,7 +3530,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw-entry] @@ -3472,6 +3540,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3508,7 +3577,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw_2-entry] @@ -3518,6 +3587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3554,7 +3624,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-entry] @@ -3564,6 +3634,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3611,6 +3682,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3660,6 +3732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3711,6 +3784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3762,6 +3836,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3813,6 +3888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3864,6 +3940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3915,6 +3992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3966,6 +4044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4017,6 +4096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4073,6 +4153,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4133,6 +4214,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4186,6 +4268,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4232,6 +4315,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4280,6 +4364,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4331,6 +4416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4382,6 +4468,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4433,6 +4520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4484,6 +4572,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4535,6 +4624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4584,6 +4674,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4631,6 +4722,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4678,6 +4770,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4725,6 +4818,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_switch.ambr b/tests/components/myuplink/snapshots/test_switch.ambr index 5d621e661ee..142d4caa455 100644 --- a/tests/components/myuplink/snapshots/test_switch.ambr +++ b/tests/components/myuplink/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index fda0d3526f9..320bf202024 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from aiohttp import ClientConnectionError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState @@ -214,3 +215,47 @@ async def test_device_remove_devices( old_device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +@pytest.mark.parametrize( + "load_systems_file", + [load_fixture("systems-multi.json", DOMAIN)], + ids=[ + "multi", + ], +) +@pytest.mark.parametrize( + ("load_device_file", "device_id"), + [ + ( + load_fixture("device-alfred.json", DOMAIN), + "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-batman.json", DOMAIN), + "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-robin.json", DOMAIN), + "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ], + ids=[ + "alfred", + "batman", + "robin", + ], +) +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + device_id: str, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index 16129c5d7ce..429d069b741 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -170,6 +173,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -224,6 +228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -278,6 +283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,6 +338,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -386,6 +393,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +448,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -494,6 +503,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -602,6 +613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -654,6 +666,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -703,6 +716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -755,6 +769,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -809,6 +824,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -865,6 +881,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -919,6 +936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -973,6 +991,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1025,6 +1044,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1079,6 +1099,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1135,6 +1156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1189,6 +1211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1243,6 +1266,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1297,6 +1321,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1351,6 +1376,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1403,6 +1429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1457,6 +1484,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1513,6 +1541,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1567,6 +1596,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1621,6 +1651,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1675,6 +1706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 6c11399c888..80c6e86f420 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -6,16 +6,16 @@ from unittest.mock import patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest -from homeassistant.components import zeroconf from homeassistant.components.nam.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("10.10.2.3"), ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 97a314b0bf4..ba89405bc97 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -9,11 +9,15 @@ from aionanoleaf import InvalidToken, Unauthorized, Unavailable import pytest from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.nanoleaf.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -248,13 +252,13 @@ async def test_discovery_link_unavailable( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery_info, ), ) @@ -384,13 +388,13 @@ async def test_import_discovery_integration( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, + properties={ATTR_PROPERTIES_ID: TEST_DEVICE_ID}, type=type_in_discovery, ), ) @@ -432,7 +436,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index b5e3cd2b91c..92d90a18a7e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -74,20 +74,25 @@ class FakeAuth: self.json = None self.headers = None self.captured_requests = [] + self._project_id = project_id + self._aioclient_mock = aioclient_mock + self.register_mock_requests() + def register_mock_requests(self) -> None: + """Register the mocks.""" # API makes a call to request structures to initiate pubsub feed, but the # integration does not use this. - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/structures", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/structures", side_effect=self.request_structures, ) - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/devices", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/devices", side_effect=self.request_devices, ) - aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) - aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) - aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) + self._aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) + self._aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) + self._aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) async def request_structures( self, method: str, url: str, data: dict[str, Any] diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 98c3e06cfb8..1a5c4d63dba 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -89,80 +89,3 @@ async def test_auth( assert creds.client_id == CLIENT_ID assert creds.client_secret == CLIENT_SECRET assert creds.scopes == SDM_SCOPES - - -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize( - "token_expiration_time", - [time.time() - 7 * 86400], - ids=["expires-in-past"], -) -async def test_auth_expired_token( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - setup_platform: PlatformSetup, - token_expiration_time: float, -) -> None: - """Verify behavior of an expired token.""" - # Prepare a token refresh response - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "access_token": FAKE_UPDATED_TOKEN, - "expires_at": time.time() + 86400, - "expires_in": 86400, - }, - ) - # Prepare to capture credentials in API request. Empty payloads just mean - # no devices or structures are loaded. - aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/structures", json={}) - aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/devices", json={}) - - # Prepare to capture credentials for Subscriber - captured_creds = None - - def async_new_subscriber( - credentials: Credentials, - ) -> Mock: - """Capture credentials for tests.""" - nonlocal captured_creds - captured_creds = credentials - return AsyncMock() - - with patch( - "google_nest_sdm.subscriber_client.pubsub_v1.SubscriberAsyncClient", - side_effect=async_new_subscriber, - ) as new_subscriber_mock: - await setup_platform() - - calls = aioclient_mock.mock_calls - assert len(calls) == 3 - # Verify refresh token call to get an updated token - (method, url, data, headers) = calls[0] - assert data == { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "grant_type": "refresh_token", - "refresh_token": FAKE_REFRESH_TOKEN, - } - # Verify API requests are made with the new token - (method, url, data, headers) = calls[1] - assert headers == {"Authorization": f"Bearer {FAKE_UPDATED_TOKEN}"} - (method, url, data, headers) = calls[2] - assert headers == {"Authorization": f"Bearer {FAKE_UPDATED_TOKEN}"} - - # The subscriber is created with a token that is expired. Verify that the - # credential is expired so the subscriber knows it needs to refresh it. - assert len(new_subscriber_mock.mock_calls) == 1 - assert captured_creds - creds = captured_creds - assert creds.token == FAKE_TOKEN - assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) - assert not creds.valid - assert creds.expired - assert creds.token_uri == OAUTH2_TOKEN - assert creds.client_id == CLIENT_ID - assert creds.client_secret == CLIENT_SECRET - assert creds.scopes == SDM_SCOPES diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 3d28c1abf23..0e6ec290841 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -10,12 +10,12 @@ from google_nest_sdm.exceptions import AuthException import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .common import ( CLIENT_ID, @@ -34,9 +34,9 @@ from tests.typing import ClientSessionGenerator WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" -RAND_SUBSCRIBER_SUFFIX = "ABCDEF" +RAND_SUFFIX = "ABCDEF" -FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( +FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" ) @@ -52,7 +52,7 @@ def mock_rand_topic_name_fixture() -> None: """Set the topic name random string to a constant.""" with patch( "homeassistant.components.nest.config_flow.get_random_string", - return_value=RAND_SUBSCRIBER_SUFFIX, + return_value=RAND_SUFFIX, ): yield @@ -173,6 +173,7 @@ class OAuthFixture: selected_topic: str, selected_subscription: str = "create_new_subscription", user_input: dict | None = None, + existing_errors: dict | None = None, ) -> ConfigEntry: """Fixture to walk through the Pub/Sub topic and subscription steps. @@ -193,6 +194,12 @@ class OAuthFixture: }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + # ACK the topic selection. User is instructed to do some manual + result = await self.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert not result.get("errors") @@ -267,6 +274,12 @@ def mock_cloud_project_id() -> str: return CLOUD_PROJECT_ID +@pytest.fixture(name="create_topic_status") +def mock_create_topic_status() -> str: + """Fixture to configure the return code when creating the topic.""" + return HTTPStatus.OK + + @pytest.fixture(name="create_subscription_status") def mock_create_subscription_status() -> str: """Fixture to configure the return code when creating the subscription.""" @@ -285,6 +298,64 @@ def mock_list_subscriptions_status() -> str: return HTTPStatus.OK +def setup_mock_list_subscriptions_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + list_subscriptions_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for listing Pub/Sub subscriptions.""" + aioclient_mock.get( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", + json={ + "subscriptions": [ + { + "name": subscription_name, + "topic": topic, + "pushConfig": {}, + "ackDeadlineSeconds": 10, + "messageRetentionDuration": "604800s", + "expirationPolicy": {"ttl": "2678400s"}, + "state": "ACTIVE", + } + for (subscription_name, topic) in subscriptions or () + ] + }, + status=list_subscriptions_status, + ) + + +def setup_mock_create_topic_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_topic_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub topic.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_topic_status, + ) + aioclient_mock.post( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}:setIamPolicy", + json={}, + status=create_topic_status, + ) + + +def setup_mock_create_subscription_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_subscription_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub subscription.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_subscription_status, + ) + + @pytest.fixture(autouse=True) def mock_pubsub_api_responses( aioclient_mock: AiohttpClientMocker, @@ -293,6 +364,7 @@ def mock_pubsub_api_responses( subscriptions: list[tuple[str, str]], device_access_project_id: str, cloud_project_id: str, + create_topic_status: HTTPStatus, create_subscription_status: HTTPStatus, list_topics_status: HTTPStatus, list_subscriptions_status: HTTPStatus, @@ -320,28 +392,14 @@ def mock_pubsub_api_responses( ) # We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) # or the user has created one themselves in the Google Cloud Project. - aioclient_mock.get( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", - json={ - "subscriptions": [ - { - "name": subscription_name, - "topic": topic, - "pushConfig": {}, - "ackDeadlineSeconds": 10, - "messageRetentionDuration": "604800s", - "expirationPolicy": {"ttl": "2678400s"}, - "state": "ACTIVE", - } - for (subscription_name, topic) in subscriptions or () - ] - }, - status=list_subscriptions_status, + setup_mock_list_subscriptions_responses( + aioclient_mock, cloud_project_id, subscriptions, list_subscriptions_status ) - aioclient_mock.put( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", - json={}, - status=create_subscription_status, + setup_mock_create_topic_responses( + aioclient_mock, cloud_project_id, create_topic_status + ) + setup_mock_create_subscription_responses( + aioclient_mock, cloud_project_id, create_subscription_status ) @@ -371,7 +429,7 @@ async def test_app_credentials( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -520,6 +578,11 @@ async def test_config_flow_pubsub_configuration_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -565,6 +628,11 @@ async def test_config_flow_pubsub_subscriber_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -691,37 +759,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_pubsub_subscription_strip_whitespace( - hass: HomeAssistant, - oauth: OAuthFixture, -) -> None: - """Check that project id has whitespace stripped on entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await oauth.async_app_creds_flow( - result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " - ) - oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic="projects/sdm-prod/topics/enterprise-some-project-id" - ) - assert entry.title == "Import from configuration.yaml" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscription_name" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID - - @pytest.mark.parametrize( ("sdm_managed_topic", "create_subscription_status"), [(True, HTTPStatus.UNAUTHORIZED)], @@ -751,6 +788,11 @@ async def test_pubsub_subscription_auth_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -833,7 +875,7 @@ async def test_config_entry_title_from_home( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -905,7 +947,7 @@ async def test_title_failure_fallback( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -997,7 +1039,7 @@ async def test_dhcp_discovery_with_creds( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -1092,7 +1134,7 @@ async def test_no_eligible_topics( hass: HomeAssistant, oauth: OAuthFixture, ) -> None: - """Test the case where there are no eligible pub/sub topics.""" + """Test the case where there are no eligible pub/sub topics and the topic is created.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1101,8 +1143,36 @@ async def test_no_eligible_topics( result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" - assert result.get("errors") == {"base": "no_pubsub_topics"} + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + entry = await oauth.async_complete_pubsub_flow( + result, + selected_topic="create_new_topic", + selected_subscription="create_new_subscription", + ) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } @pytest.mark.parametrize( @@ -1122,11 +1192,90 @@ async def test_list_topics_failure( await oauth.async_app_creds_flow(result) oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "pubsub_api_error" + + +@pytest.mark.parametrize( + ("create_topic_status"), + [(HTTPStatus.INTERNAL_SERVER_ERROR)], +) +async def test_create_topic_failed( + hass: HomeAssistant, + oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + auth: FakeAuth, +) -> None: + """Test the case where there are no eligible pub/sub topics and the topic is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic" assert result.get("errors") == {"base": "pubsub_api_error"} + # Re-register mock requests needed for the rest of the test. The topic + # request will now succeed. + aioclient_mock.clear_requests() + setup_mock_create_topic_responses(aioclient_mock, cloud_project_id) + # Fix up other mock responses cleared above + auth.register_mock_requests() + setup_mock_list_subscriptions_responses( + aioclient_mock, + cloud_project_id, + subscriptions, + ) + setup_mock_create_subscription_responses(aioclient_mock, cloud_project_id) + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_subscription" + assert not result.get("errors") + + # Create a subscription for the topic and end the flow + entry = await oauth.async_finish_setup( + result, + {"subscription_name": "create_new_subscription"}, + ) + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + @pytest.mark.parametrize( ("sdm_managed_topic", "list_subscriptions_status"), @@ -1158,5 +1307,10 @@ async def test_list_subscriptions_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("errors") == {"base": "pubsub_api_error"} diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 7d04624dcc8..c7ac5875403 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -9,10 +9,12 @@ relevant modes. """ from collections.abc import Generator +import datetime from http import HTTPStatus import logging from unittest.mock import AsyncMock, patch +import aiohttp from google_nest_sdm.exceptions import ( ApiException, AuthException, @@ -22,6 +24,7 @@ from google_nest_sdm.exceptions import ( import pytest from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -36,6 +39,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker PLATFORM = "sensor" +EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() + @pytest.fixture def platforms() -> list[str]: @@ -139,6 +144,55 @@ async def test_setup_device_manager_failure( assert entries[0].state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("token_expiration_time", [EXPIRED_TOKEN_TIMESTAMP]) +@pytest.mark.parametrize( + ("token_response_args", "expected_state", "expected_steps"), + [ + # Cases that retry integration setup + ( + {"status": HTTPStatus.INTERNAL_SERVER_ERROR}, + ConfigEntryState.SETUP_RETRY, + [], + ), + ({"exc": aiohttp.ClientError("No internet")}, ConfigEntryState.SETUP_RETRY, []), + # Cases that require the user to reauthenticate in a config flow + ( + {"status": HTTPStatus.BAD_REQUEST}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ( + {"status": HTTPStatus.FORBIDDEN}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ], +) +async def test_expired_token_refresh_error( + hass: HomeAssistant, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + token_response_args: dict, + expected_state: ConfigEntryState, + expected_steps: list[str], +) -> None: + """Test errors when attempting to refresh the auth token.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + **token_response_args, + ) + + await setup_base_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is expected_state + + flows = hass.config_entries.flow.async_progress() + assert expected_steps == [flow["step_id"] for flow in flows] + + @pytest.mark.parametrize("subscriber_side_effect", [AuthException()]) async def test_subscriber_auth_failure( hass: HomeAssistant, diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 276dd45d0ab..d009e1185da 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEVICE_ID, @@ -1012,9 +1012,9 @@ async def test_media_permission_unauthorized( client = await hass_client() response = await client.get(media_url) - assert ( - response.status == HTTPStatus.UNAUTHORIZED - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.UNAUTHORIZED, ( + f"Response not matched: {response}" + ) async def test_multiple_devices( @@ -1306,9 +1306,9 @@ async def test_media_store_load_filesystem_error( response = await client.get( f"/api/nest/event_media/{device.id}/{event_identifier}" ) - assert ( - response.status == HTTPStatus.NOT_FOUND - ), f"Response not matched: {response}" + assert response.status == HTTPStatus.NOT_FOUND, ( + f"Response not matched: {response}" + ) @pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 730cb0cb117..9110f8c724f 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -11,7 +11,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util.aiohttp import MockRequest from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr index 6a90b4dd77a..3066c999655 100644 --- a/tests/components/netatmo/snapshots/test_binary_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -206,6 +210,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +310,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -352,6 +359,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,6 +410,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +459,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -498,6 +508,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr new file mode 100644 index 00000000000..086403c3b69 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entity[button.bubendorff_blind_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999993-DeviceType.NBO-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.bubendorff_blind_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bubendorff blind Preferred position', + }), + 'context': , + 'entity_id': 'button.bubendorff_blind_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.entrance_blinds_preferred_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preferred position', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preferred_position', + 'unique_id': '0009999992-DeviceType.NBR-preferred_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[button.entrance_blinds_preferred_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Entrance Blinds Preferred position', + }), + 'context': , + 'entity_id': 'button.entrance_blinds_preferred_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr index 94a5ded5031..9bd10ed9b5f 100644 --- a/tests/components/netatmo/snapshots/test_camera.ambr +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -128,6 +130,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index aeae1fd71c7..506e0fb5590 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -20,6 +20,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -95,6 +96,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +178,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +259,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -338,6 +342,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr index 7ea016f5ae8..46aafb32e8e 100644 --- a/tests/components/netatmo/snapshots/test_cover.ambr +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 463556ec657..4ea7e30bcf9 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -646,6 +646,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'netatmo', 'version': 1, diff --git a/tests/components/netatmo/snapshots/test_fan.ambr b/tests/components/netatmo/snapshots/test_fan.ambr index ba882d68e50..f850f7ada3b 100644 --- a/tests/components/netatmo/snapshots/test_fan.ambr +++ b/tests/components/netatmo/snapshots/test_fan.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 60cb22d74f2..35e7f7efc29 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'corridor', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -131,6 +135,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -163,6 +168,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -195,6 +201,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -227,6 +234,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -259,6 +267,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -291,6 +300,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -323,6 +333,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -355,6 +366,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -387,6 +399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -419,6 +432,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -451,6 +465,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -483,6 +498,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -515,6 +531,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -547,6 +564,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -579,6 +597,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -611,6 +630,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -643,6 +663,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -675,6 +696,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -707,6 +729,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -739,6 +762,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -771,6 +795,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -803,6 +828,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -835,6 +861,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -867,6 +894,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -899,6 +927,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -931,6 +960,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -963,6 +993,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -995,6 +1026,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'bureau', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1027,6 +1059,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'livingroom', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1059,6 +1092,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'entrada', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1091,6 +1125,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'cocina', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1123,6 +1158,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1155,6 +1191,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1187,6 +1224,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1219,6 +1257,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), diff --git a/tests/components/netatmo/snapshots/test_light.ambr b/tests/components/netatmo/snapshots/test_light.ambr index fe5a8aac7d0..cc7da6e8712 100644 --- a/tests/components/netatmo/snapshots/test_light.ambr +++ b/tests/components/netatmo/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +123,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_select.ambr b/tests/components/netatmo/snapshots/test_select.ambr index ff68fc71c09..d98d9adb87f 100644 --- a/tests/components/netatmo/snapshots/test_select.ambr +++ b/tests/components/netatmo/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index ba18c2ca21a..b149e80fa5b 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -128,6 +130,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -187,6 +190,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +245,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -393,6 +400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -448,6 +456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -497,6 +506,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +617,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -664,6 +676,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -721,6 +734,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -773,6 +787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -823,6 +838,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -870,6 +886,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -921,6 +938,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -974,6 +992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1021,6 +1040,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1072,6 +1092,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1122,6 +1143,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1169,6 +1191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1241,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1270,6 +1294,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1320,6 +1345,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1367,6 +1393,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1416,6 +1443,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1476,6 +1504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1529,6 +1558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1583,6 +1613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1637,6 +1668,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1690,6 +1722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1744,6 +1777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1801,6 +1835,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1855,6 +1890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1912,6 +1948,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1966,6 +2003,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2026,6 +2064,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2079,6 +2118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2133,6 +2173,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2187,6 +2228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2240,6 +2282,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2294,6 +2337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2351,6 +2395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2405,6 +2450,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2462,6 +2508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2516,6 +2563,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2576,6 +2624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2629,6 +2678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2683,6 +2733,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2737,6 +2788,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2790,6 +2842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2844,6 +2897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2901,6 +2955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2955,6 +3010,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3012,6 +3068,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3064,6 +3121,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3113,6 +3171,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3173,6 +3232,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3233,6 +3293,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3292,6 +3353,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3346,6 +3408,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3398,6 +3461,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3447,6 +3511,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3498,6 +3563,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3553,6 +3619,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3602,6 +3669,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3651,6 +3719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3698,6 +3767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3745,6 +3815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3792,6 +3863,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3839,6 +3911,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3888,6 +3961,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3948,6 +4022,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4000,6 +4075,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4060,6 +4136,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4119,6 +4196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4173,6 +4251,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4225,6 +4304,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4274,6 +4354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4325,6 +4406,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4380,6 +4462,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4429,6 +4512,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4480,6 +4564,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4540,6 +4625,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4600,6 +4686,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4659,6 +4746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4713,6 +4801,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4765,6 +4854,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4814,6 +4904,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4865,6 +4956,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4920,6 +5012,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4969,6 +5062,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5018,6 +5112,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5067,6 +5162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5117,6 +5213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5166,6 +5263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5218,6 +5316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5270,6 +5369,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5330,6 +5430,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5382,6 +5483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5434,6 +5536,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5484,6 +5587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5531,6 +5635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5580,6 +5685,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5633,6 +5739,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5682,6 +5789,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5734,6 +5842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5786,6 +5895,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5836,6 +5946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5883,6 +5994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5932,6 +6044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5985,6 +6098,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6034,6 +6148,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6088,6 +6203,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6140,6 +6256,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6200,6 +6317,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6260,6 +6378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6310,6 +6429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6357,6 +6477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6406,6 +6527,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6466,6 +6588,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6526,6 +6649,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6578,6 +6702,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6632,6 +6757,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6686,6 +6812,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6738,6 +6865,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6788,6 +6916,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6835,6 +6964,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6884,6 +7014,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6937,6 +7068,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6984,6 +7116,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7035,6 +7168,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7087,6 +7221,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7139,6 +7274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7194,6 +7330,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7244,6 +7381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7291,6 +7429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7338,6 +7477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7389,6 +7529,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7444,6 +7585,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7493,6 +7635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_switch.ambr b/tests/components/netatmo/snapshots/test_switch.ambr index 4244917d86f..f44cbcd22a5 100644 --- a/tests/components/netatmo/snapshots/test_switch.ambr +++ b/tests/components/netatmo/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py new file mode 100644 index 00000000000..bffecf7d83a --- /dev/null +++ b/tests/components/netatmo/test_button.py @@ -0,0 +1,72 @@ +"""The tests for Netatmo button.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.BUTTON, + entity_registry, + snapshot, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + button_entity = "button.entrance_blinds_preferred_position" + + assert hass.states.get(button_entity).state == STATE_UNKNOWN + + # Test button press + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: button_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": -2, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + assert (state := hass.states.get(button_entity)) + assert state.state != STATE_UNKNOWN diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 43904ed8f71..32f20544043 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -19,7 +19,7 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import ( diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index dc0312f7acd..18c811fd76b 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 436f75b12ec..f5714d69a98 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ from pyatmo.const import ALL_SCOPES import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, @@ -20,6 +19,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from .conftest import CLIENT_ID @@ -46,13 +49,13 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index 509c1de736e..9368a564afb 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py index 989ea1ac364..3dbc8b3a6f5 100644 --- a/tests/components/netatmo/test_fan.py +++ b/tests/components/netatmo/test_fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index c90d67e7630..0932395b8ec 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( from homeassistant.components.netatmo import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( FAKE_WEBHOOK_ACTIVATION, diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 274113405f6..458115f8f5c 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index dd82fad3d08..837f6201b1e 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 724a0568580..3c83bb57c43 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import Mock, patch from pynetgear import DEFAULT_USER import pytest -from homeassistant.components import ssdp from homeassistant.components.netgear.const import ( CONF_CONSIDER_HOME, DOMAIN, @@ -23,6 +22,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -208,14 +213,14 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -228,13 +233,13 @@ async def test_ssdp_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, }, ), ) @@ -253,14 +258,14 @@ async def test_ssdp_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URLipv6, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -273,14 +278,14 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", - ssdp.ATTR_UPNP_PRESENTATION_URL: URL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: "RBR20", + ATTR_UPNP_PRESENTATION_URL: URL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) @@ -305,14 +310,14 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_URL_SLL, upnp={ - ssdp.ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], - ssdp.ATTR_UPNP_PRESENTATION_URL: URL_SSL, - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], + ATTR_UPNP_PRESENTATION_URL: URL_SSL, + ATTR_UPNP_SERIAL: SERIAL, }, ), ) diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index ca65c17cc8e..2a806be8ae1 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.5.1', 'connections': set({ }), diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py index 1bd3dff1eff..e853109e33e 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import CONF_DATA diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py index f3ccacbd064..bbac7ca2f7c 100644 --- a/tests/components/network/__init__.py +++ b/tests/components/network/__init__.py @@ -1 +1,4 @@ """Tests for the Network Configuration integration.""" + +NO_LOOPBACK_IPADDR = "192.168.1.5" +LOOPBACK_IPADDR = "127.0.0.1" diff --git a/tests/components/network/conftest.py b/tests/components/network/conftest.py index d5fbb95a814..db2f268e968 100644 --- a/tests/components/network/conftest.py +++ b/tests/components/network/conftest.py @@ -1,14 +1,51 @@ """Tests for the Network Configuration integration.""" from collections.abc import Generator -from unittest.mock import _patch +from unittest.mock import MagicMock, Mock, _patch, patch +import ifaddr import pytest +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + + +def _generate_mock_adapters(): + mock_lo0 = Mock(spec=ifaddr.Adapter) + mock_lo0.nice_name = "lo0" + mock_lo0.ips = [ifaddr.IP(LOOPBACK_IPADDR, 8, "lo0")] + mock_lo0.index = 0 + mock_eth0 = Mock(spec=ifaddr.Adapter) + mock_eth0.nice_name = "eth0" + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth0.index = 1 + mock_eth1 = Mock(spec=ifaddr.Adapter) + mock_eth1.nice_name = "eth1" + mock_eth1.ips = [ifaddr.IP(NO_LOOPBACK_IPADDR, 23, "eth1")] + mock_eth1.index = 2 + mock_vtun0 = Mock(spec=ifaddr.Adapter) + mock_vtun0.nice_name = "vtun0" + mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] + mock_vtun0.index = 3 + return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] + + +def _mock_socket(sockname: list[str]) -> Generator[None]: + """Mock the network socket.""" + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=sockname)), + ): + yield + @pytest.fixture(autouse=True) -def mock_network(): +def mock_network() -> Generator[None]: """Override mock of network util's async_get_adapters.""" + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + yield @pytest.fixture(autouse=True) @@ -19,3 +56,21 @@ def override_mock_get_source_ip( mock_get_source_ip.stop() yield mock_get_source_ip.start() + + +@pytest.fixture +def mock_socket(request: pytest.FixtureRequest) -> Generator[None]: + """Mock the network socket.""" + yield from _mock_socket(request.param) + + +@pytest.fixture +def mock_socket_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([LOOPBACK_IPADDR]) + + +@pytest.fixture +def mock_socket_no_loopback() -> Generator[None]: + """Mock the network socket with loopback address.""" + yield from _mock_socket([NO_LOOPBACK_IPADDR]) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index dca31106dba..a2352e6af9e 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -4,7 +4,6 @@ from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch -import ifaddr import pytest from homeassistant.components import network @@ -20,17 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR + from tests.typing import WebSocketGenerator -_NO_LOOPBACK_IPADDR = "192.168.1.5" -_LOOPBACK_IPADDR = "127.0.0.1" - - -def _mock_socket(sockname): - mock_socket = MagicMock() - mock_socket.getsockname = Mock(return_value=sockname) - return mock_socket - def _mock_cond_socket(sockname): class CondMockSock(MagicMock): @@ -54,42 +46,13 @@ def _mock_socket_exception(exc): return mock_socket -def _generate_mock_adapters(): - mock_lo0 = Mock(spec=ifaddr.Adapter) - mock_lo0.nice_name = "lo0" - mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")] - mock_lo0.index = 0 - mock_eth0 = Mock(spec=ifaddr.Adapter) - mock_eth0.nice_name = "eth0" - mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] - mock_eth0.index = 1 - mock_eth1 = Mock(spec=ifaddr.Adapter) - mock_eth1.nice_name = "eth1" - mock_eth1.ips = [ifaddr.IP("192.168.1.5", 23, "eth1")] - mock_eth1.index = 2 - mock_vtun0 = Mock(spec=ifaddr.Adapter) - mock_vtun0.nice_name = "vtun0" - mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] - mock_vtun0.index = 3 - return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] - - +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_detect_interfaces_setting_non_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a non-loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -141,22 +104,13 @@ async def test_async_detect_interfaces_setting_non_loopback_route( ] +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_detect_interfaces_setting_loopback_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns a loopback address.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -207,22 +161,14 @@ async def test_async_detect_interfaces_setting_loopback_route( ] +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_detect_interfaces_setting_empty_route( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route returns nothing.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == [] @@ -277,15 +223,9 @@ async def test_async_detect_interfaces_setting_exception( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test without default interface config and the route throws an exception.""" - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket_exception(AttributeError), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket_exception(AttributeError), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -339,6 +279,7 @@ async def test_async_detect_interfaces_setting_exception( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_interfaces_configured_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -348,18 +289,9 @@ async def test_interfaces_configured_from_storage( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() network_obj = hass.data[DOMAIN] assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] @@ -422,15 +354,9 @@ async def test_interfaces_configured_from_storage_websocket_update( "key": STORAGE_KEY, "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_NO_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), + with patch( + "homeassistant.components.network.util.socket.socket", + return_value=MagicMock(getsockname=Mock(return_value=[NO_LOOPBACK_IPADDR])), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -546,6 +472,7 @@ async def test_interfaces_configured_from_storage_websocket_update( ] +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_matching_interface( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -556,22 +483,13 @@ async def test_async_get_source_ip_matching_interface( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_interface_not_match( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -582,22 +500,14 @@ async def test_async_get_source_ip_interface_not_match( "data": {ATTR_CONFIGURED_ADAPTERS: ["vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_determine_target( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -608,22 +518,13 @@ async def test_async_get_source_ip_cannot_determine_target( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == NO_LOOPBACK_IPADDR +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_ipv4_broadcast_addresses_default( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -634,24 +535,15 @@ async def test_async_get_ipv4_broadcast_addresses_default( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255") } +@pytest.mark.usefixtures("mock_socket_loopback") async def test_async_get_ipv4_broadcast_addresses_multiple( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -662,18 +554,8 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1", "vtun0"]}, } - with ( - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([_LOOPBACK_IPADDR]), - ), - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=_generate_mock_adapters(), - ), - ): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert await network.async_get_ipv4_broadcast_addresses(hass) == { IPv4Address("255.255.255.255"), @@ -682,6 +564,7 @@ async def test_async_get_ipv4_broadcast_addresses_multiple( } +@pytest.mark.usefixtures("mock_socket_no_loopback") async def test_async_get_source_ip_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -692,24 +575,23 @@ async def test_async_get_source_ip_no_enabled_addresses( "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket(["192.168.1.5"]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + assert ( + await network.async_get_source_ip(hass, MDNS_TARGET_IP) + == NO_LOOPBACK_IPADDR + ) assert "source address detection may be inaccurate" in caplog.text +@pytest.mark.parametrize("mock_socket", [[None]], indirect=True) +@pytest.mark.usefixtures("mock_socket") async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: @@ -720,15 +602,9 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, } - with ( - patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[], - ), - patch( - "homeassistant.components.network.util.socket.socket", - return_value=_mock_socket([None]), - ), + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], ): assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -753,7 +629,7 @@ async def test_async_get_source_ip_no_ip_loopback( ), patch( "homeassistant.components.network.util.socket.socket", - return_value=_mock_cond_socket(_LOOPBACK_IPADDR), + return_value=_mock_cond_socket(LOOPBACK_IPADDR), ), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/network/test_system_health.py b/tests/components/network/test_system_health.py new file mode 100644 index 00000000000..eb383aafde7 --- /dev/null +++ b/tests/components/network/test_system_health.py @@ -0,0 +1,32 @@ +"""Test network system health.""" + +import asyncio + +import pytest + +from homeassistant.components.network.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import get_system_health_info + + +@pytest.mark.usefixtures("mock_socket_no_loopback") +async def test_network_system_health(hass: HomeAssistant) -> None: + """Test network system health.""" + + assert await async_setup_component(hass, "system_health", {}) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + info = await get_system_health_info(hass, "network") + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == { + "adapters": "eth0 (disabled), lo0 (disabled), eth1 (enabled, default, auto), vtun0 (disabled)", + "announce_addresses": "192.168.1.5", + "ipv4_addresses": "eth0 (), lo0 (127.0.0.1/8), eth1 (192.168.1.5/23), vtun0 (169.254.3.2/16)", + "ipv6_addresses": "eth0 (2001:db8::/8), lo0 (), eth1 (), vtun0 ()", + } diff --git a/tests/components/nextcloud/snapshots/test_binary_sensor.ambr b/tests/components/nextcloud/snapshots/test_binary_sensor.ambr index 1831419af52..578659d411d 100644 --- a/tests/components/nextcloud/snapshots/test_binary_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index c49ba3496da..84c1d33f886 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -155,6 +158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -253,6 +258,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,6 +308,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -351,6 +358,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -400,6 +408,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -449,6 +458,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -498,6 +508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -547,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -596,6 +608,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -645,6 +658,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -694,6 +708,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -743,6 +758,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -792,6 +808,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -841,6 +858,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -890,6 +908,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -939,6 +958,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -986,6 +1006,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1053,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1079,7 +1101,7 @@ 'state': '0.175296', }) # --- -# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entires-entry] +# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entries-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1088,12 +1110,13 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entires', + 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entries', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1105,7 +1128,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cache number of entires', + 'original_name': 'Cache number of entries', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1114,14 +1137,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entires-state] +# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entries-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local Cache number of entires', + 'friendly_name': 'my.nc_url.local Cache number of entries', 'state_class': , }), 'context': , - 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entires', + 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entries', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1137,6 +1160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1186,6 +1210,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1235,6 +1260,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1284,6 +1310,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1331,6 +1358,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1378,6 +1406,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1424,6 +1453,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1474,6 +1504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1524,6 +1555,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1574,6 +1606,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1628,6 +1661,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1674,6 +1708,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1720,6 +1755,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1774,6 +1810,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1828,6 +1865,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1882,6 +1920,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1936,6 +1975,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1992,6 +2032,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2039,6 +2080,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2093,6 +2135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2147,6 +2190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2201,6 +2245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2247,6 +2292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2293,6 +2339,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2341,6 +2388,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2391,6 +2439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2440,6 +2489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2489,6 +2539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2536,6 +2587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2586,6 +2638,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2642,6 +2695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2689,6 +2743,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2741,6 +2796,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2788,6 +2844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2837,6 +2894,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2886,6 +2944,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2935,6 +2994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2984,6 +3044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3031,6 +3092,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3078,6 +3140,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3132,6 +3195,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3186,6 +3250,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3234,6 +3299,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3288,6 +3354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3342,6 +3409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3388,6 +3456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3444,6 +3513,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3491,6 +3561,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3545,6 +3616,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3591,6 +3663,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3637,6 +3710,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3683,6 +3757,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3729,6 +3804,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3775,6 +3851,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3829,6 +3906,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3885,6 +3963,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3932,6 +4011,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextcloud/snapshots/test_update.ambr b/tests/components/nextcloud/snapshots/test_update.ambr index 484106580b1..a8acd2f5294 100644 --- a/tests/components/nextcloud/snapshots/test_update.ambr +++ b/tests/components/nextcloud/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_binary_sensor.ambr b/tests/components/nextdns/snapshots/test_binary_sensor.ambr index 814b4c1ac16..65a477f50f3 100644 --- a/tests/components/nextdns/snapshots/test_binary_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_button.ambr b/tests/components/nextdns/snapshots/test_button.ambr index 32dc31eea19..3f1f75d1783 100644 --- a/tests/components/nextdns/snapshots/test_button.ambr +++ b/tests/components/nextdns/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 827d6aeb6e5..23f42fee077 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Fake Profile', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nextdns/snapshots/test_sensor.ambr b/tests/components/nextdns/snapshots/test_sensor.ambr index 14bebea53f8..48c3b0894db 100644 --- a/tests/components/nextdns/snapshots/test_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +365,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -408,6 +416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,6 +518,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -608,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +773,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -808,6 +824,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -858,6 +875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -908,6 +926,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -958,6 +977,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1008,6 +1028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1058,6 +1079,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1108,6 +1130,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1158,6 +1181,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1208,6 +1232,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_switch.ambr b/tests/components/nextdns/snapshots/test_switch.ambr index 3328e341a2e..e6d63b7f542 100644 --- a/tests/components/nextdns/snapshots/test_switch.ambr +++ b/tests/components/nextdns/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -466,6 +476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -604,6 +617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -650,6 +664,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -696,6 +711,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -742,6 +758,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,6 +805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -834,6 +852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -880,6 +899,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -926,6 +946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -972,6 +993,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1040,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1064,6 +1087,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +1134,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1156,6 +1181,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1202,6 +1228,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1248,6 +1275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1294,6 +1322,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1340,6 +1369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1386,6 +1416,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1432,6 +1463,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1478,6 +1510,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1524,6 +1557,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1570,6 +1604,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1616,6 +1651,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1662,6 +1698,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1708,6 +1745,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1754,6 +1792,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1800,6 +1839,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1846,6 +1886,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1892,6 +1933,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1980,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1984,6 +2027,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2030,6 +2074,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2076,6 +2121,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2122,6 +2168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2168,6 +2215,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2214,6 +2262,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2260,6 +2309,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2306,6 +2356,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2352,6 +2403,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2398,6 +2450,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2444,6 +2497,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2490,6 +2544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2536,6 +2591,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2582,6 +2638,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2628,6 +2685,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2674,6 +2732,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2720,6 +2779,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2766,6 +2826,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2812,6 +2873,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2858,6 +2920,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2904,6 +2967,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2950,6 +3014,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2996,6 +3061,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3042,6 +3108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3088,6 +3155,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3134,6 +3202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3180,6 +3249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3226,6 +3296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3272,6 +3343,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3318,6 +3390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nice_go/snapshots/test_cover.ambr b/tests/components/nice_go/snapshots/test_cover.ambr index 49b5267df56..0e1f9013a94 100644 --- a/tests/components/nice_go/snapshots/test_cover.ambr +++ b/tests/components/nice_go/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index f4ba363a421..b33726d2b72 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -60,6 +60,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nice_go/snapshots/test_light.ambr b/tests/components/nice_go/snapshots/test_light.ambr index 529df95a570..2b88b7d8d74 100644 --- a/tests/components/nice_go/snapshots/test_light.ambr +++ b/tests/components/nice_go/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nice_go/test_cover.py b/tests/components/nice_go/test_cover.py index f90c2d438b0..542b1717d88 100644 --- a/tests/components/nice_go/test_cover.py +++ b/tests/components/nice_go/test_cover.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -154,3 +154,86 @@ async def test_cover_exceptions( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + def _open_side_effect(*args, **kwargs): + if mock_nice_go.open_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 5: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 6: + raise ApiError + + def _close_side_effect(*args, **kwargs): + if mock_nice_go.close_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.close_barrier.call_count == 4: + raise ApiError + + mock_nice_go.open_barrier.side_effect = _open_side_effect + mock_nice_go.close_barrier.side_effect = _close_side_effect + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.open_barrier.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.close_barrier.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.open_barrier.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.open_barrier.call_count == 6 + assert mock_nice_go.close_barrier.call_count == 4 diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index b170a0ee3ab..2bc9de59b2b 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -160,3 +160,86 @@ async def test_unsupported_device_type( "Please create an issue with your device model in additional info" in caplog.text ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.light_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.light_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_off.call_count == 4: + raise ApiError + + mock_nice_go.light_on.side_effect = _on_side_effect + mock_nice_go.light_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.light_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.light_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.light_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.light_on.call_count == 6 + assert mock_nice_go.light_off.call_count == 4 diff --git a/tests/components/nice_go/test_switch.py b/tests/components/nice_go/test_switch.py index d3a2141eb2b..cab009c5b94 100644 --- a/tests/components/nice_go/test_switch.py +++ b/tests/components/nice_go/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from homeassistant.components.switch import ( @@ -88,3 +88,86 @@ async def test_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_off.call_count == 4: + raise ApiError + + mock_nice_go.vacation_mode_on.side_effect = _on_side_effect + mock_nice_go.vacation_mode_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.vacation_mode_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.vacation_mode_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.vacation_mode_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.vacation_mode_on.call_count == 6 + assert mock_nice_go.vacation_mode_off.call_count == 4 diff --git a/tests/components/niko_home_control/snapshots/test_cover.ambr b/tests/components/niko_home_control/snapshots/test_cover.ambr index 6f99c1adb8c..5fe89497298 100644 --- a/tests/components/niko_home_control/snapshots/test_cover.ambr +++ b/tests/components/niko_home_control/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/niko_home_control/snapshots/test_light.ambr b/tests/components/niko_home_control/snapshots/test_light.ambr index 702b7326ee2..adb0e743786 100644 --- a/tests/components/niko_home_control/snapshots/test_light.ambr +++ b/tests/components/niko_home_control/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/niko_home_control/test_light.py b/tests/components/niko_home_control/test_light.py index a61cc5204f6..865e1303cb0 100644 --- a/tests/components/niko_home_control/test_light.py +++ b/tests/components/niko_home_control/test_light.py @@ -42,11 +42,11 @@ async def test_entities( @pytest.mark.parametrize( ("light_id", "data", "set_brightness"), [ - (0, {ATTR_ENTITY_ID: "light.light"}, 100.0), + (0, {ATTR_ENTITY_ID: "light.light"}, 100), ( 1, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, - 19.607843137254903, + 20, ), ], ) diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py new file mode 100644 index 00000000000..3d284e5bb77 --- /dev/null +++ b/tests/components/nmbs/__init__.py @@ -0,0 +1 @@ +"""Tests for the NMBS integration.""" diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py new file mode 100644 index 00000000000..a39334ba62c --- /dev/null +++ b/tests/components/nmbs/conftest.py @@ -0,0 +1,59 @@ +"""NMBS tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyrail.models import StationsApiResponse +import pytest + +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_TO, + DOMAIN, +) + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nmbs.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nmbs_client() -> Generator[AsyncMock]: + """Mock a NMBS client.""" + with ( + patch( + "homeassistant.components.nmbs.iRail", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nmbs.config_flow.iRail", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_stations.return_value = StationsApiResponse.from_dict( + load_json_object_fixture("stations.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi", + data={ + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_TO: "BE.NMBS.008814001", + }, + unique_id="BE.NMBS.008812005_BE.NMBS.008814001", + ) diff --git a/tests/components/nmbs/fixtures/stations.json b/tests/components/nmbs/fixtures/stations.json new file mode 100644 index 00000000000..b774e064f78 --- /dev/null +++ b/tests/components/nmbs/fixtures/stations.json @@ -0,0 +1,30 @@ +{ + "version": "1.3", + "timestamp": "1720252400", + "station": [ + { + "@id": "http://irail.be/stations/NMBS/008812005", + "id": "BE.NMBS.008812005", + "name": "Brussels-North", + "locationX": "4.360846", + "locationY": "50.859663", + "standardname": "Brussel-Noord/Bruxelles-Nord" + }, + { + "@id": "http://irail.be/stations/NMBS/008813003", + "id": "BE.NMBS.008813003", + "name": "Brussels-Central", + "locationX": "4.356801", + "locationY": "50.845658", + "standardname": "Brussel-Centraal/Bruxelles-Central" + }, + { + "@id": "http://irail.be/stations/NMBS/008814001", + "id": "BE.NMBS.008814001", + "name": "Brussels-South/Brussels-Midi", + "locationX": "4.336531", + "locationY": "50.835707", + "standardname": "Brussel-Zuid/Bruxelles-Midi" + } + ] +} diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py new file mode 100644 index 00000000000..7e0f087607b --- /dev/null +++ b/tests/components/nmbs/test_config_flow.py @@ -0,0 +1,341 @@ +"""Test the NMBS config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.nmbs.config_flow import CONF_EXCLUDE_VIAS +from homeassistant.components.nmbs.const import ( + CONF_STATION_FROM, + CONF_STATION_LIVE, + CONF_STATION_TO, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +DUMMY_DATA_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussel-Noord/Bruxelles-Nord", + "STAT_BRUSSELS_CENTRAL": "Brussel-Centraal/Bruxelles-Central", + "STAT_BRUSSELS_SOUTH": "Brussel-Zuid/Bruxelles-Midi", +} + +DUMMY_DATA_ALTERNATIVE_IMPORT: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "Brussels-North", + "STAT_BRUSSELS_CENTRAL": "Brussels-Central", + "STAT_BRUSSELS_SOUTH": "Brussels-South/Brussels-Midi", +} + +DUMMY_DATA: dict[str, Any] = { + "STAT_BRUSSELS_NORTH": "BE.NMBS.008812005", + "STAT_BRUSSELS_CENTRAL": "BE.NMBS.008813003", + "STAT_BRUSSELS_SOUTH": "BE.NMBS.008814001", +} + + +async def test_full_flow( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + } + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + + +async def test_same_station( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test selecting the same station.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "same_station"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_abort_if_exists( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dont_abort_if_exists_when_vias_differs( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + CONF_EXCLUDE_VIAS: True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_unavailable_api( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by user and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +async def test_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test starting a flow by user which filled in data for connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == "Train from Brussel-Noord/Bruxelles-Nord to Brussel-Zuid/Bruxelles-Midi" + ) + assert result["data"] == { + CONF_STATION_FROM: "BE.NMBS.008812005", + CONF_STATION_LIVE: "BE.NMBS.008813003", + CONF_STATION_TO: "BE.NMBS.008814001", + } + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + + +async def test_step_import_abort_if_already_setup( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by user which filled in data for connection for already existing connection.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_unavailable_api_import( + hass: HomeAssistant, mock_nmbs_client: AsyncMock +) -> None: + """Test starting a flow by import and api is unavailable.""" + mock_nmbs_client.get_stations.return_value = None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_CENTRAL"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_unavailable" + + +@pytest.mark.parametrize( + ("config", "reason"), + [ + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: "Utrecht Centraal", + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: "Utrecht Centraal", + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + "invalid_station", + ), + ( + { + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + }, + "same_station", + ), + ], +) +async def test_invalid_station_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + config: dict[str, Any], + reason: str, +) -> None: + """Test importing invalid YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_sensor_id_migration_standardname( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + old_unique_id = ( + f"live_{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_IMPORT['STAT_BRUSSELS_SOUTH']}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id + + +async def test_sensor_id_migration_localized_name( + hass: HomeAssistant, + mock_nmbs_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + old_unique_id = ( + f"live_{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA_ALTERNATIVE_IMPORT['STAT_BRUSSELS_SOUTH']}" + ) + new_unique_id = ( + f"nmbs_live_{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_" + f"{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) + old_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, old_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_LIVE: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_FROM: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA_ALTERNATIVE_IMPORT["STAT_BRUSSELS_SOUTH"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry_id = result["result"].entry_id + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert len(entities) == 3 + entities_map = {entity.unique_id: entity for entity in entities} + assert old_unique_id not in entities_map + assert new_unique_id in entities_map + assert entities_map[new_unique_id].id == old_entry.id diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 9b328c3a71d..86aa49357c5 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -107,6 +109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +211,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -306,6 +312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +365,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -410,6 +418,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -516,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -567,6 +578,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +626,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +676,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -716,6 +730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -769,6 +784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -820,6 +836,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,6 +884,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -916,6 +934,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -969,6 +988,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1042,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1073,6 +1094,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1120,6 +1142,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1167,6 +1190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1265,6 +1290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1318,6 +1344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1371,6 +1398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1470,6 +1499,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1517,6 +1547,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1569,6 +1600,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1621,6 +1653,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1674,6 +1707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1727,6 +1761,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1778,6 +1813,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1825,6 +1861,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1874,6 +1911,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1927,6 +1965,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1980,6 +2019,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2031,6 +2071,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2078,6 +2119,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2127,6 +2169,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2180,6 +2223,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2233,6 +2277,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2284,6 +2329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2331,6 +2377,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2378,6 +2425,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 890ce2dfc4a..c1d1bd1bb2e 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -37,6 +37,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "bridges": [ diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index ad987325b97..96d5e815ff0 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index bc00df126e5..5e3ba384b2d 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant.components.nuheat.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_CONFIG_ENTRY, diff --git a/tests/components/nuki/snapshots/test_binary_sensor.ambr b/tests/components/nuki/snapshots/test_binary_sensor.ambr index 55976bcb433..e48cc55bfb3 100644 --- a/tests/components/nuki/snapshots/test_binary_sensor.ambr +++ b/tests/components/nuki/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nuki/snapshots/test_lock.ambr b/tests/components/nuki/snapshots/test_lock.ambr index 24c80e7b487..2d80110a5cc 100644 --- a/tests/components/nuki/snapshots/test_lock.ambr +++ b/tests/components/nuki/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nuki/snapshots/test_sensor.ambr b/tests/components/nuki/snapshots/test_sensor.ambr index a319104fbc3..5be025727be 100644 --- a/tests/components/nuki/snapshots/test_sensor.ambr +++ b/tests/components/nuki/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index d4ddc261f1e..efc6c6c5abc 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -6,11 +6,11 @@ from pynuki.bridge import InvalidCredentialsException from requests.exceptions import RequestException from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.nuki.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .mock import DHCP_FORMATTED_MAC, HOST, MOCK_INFO, NAME, setup_nuki_integration @@ -151,9 +151,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) @@ -196,9 +194,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: await setup_nuki_integration(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( - hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC - ), + data=DhcpServiceInfo(hostname=NAME, ip=HOST, macaddress=DHCP_FORMATTED_MAC), context={"source": config_entries.SOURCE_DHCP}, ) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 31d99dc55d7..7b19879d873 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM @@ -974,7 +974,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test number platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/nut/conftest.py b/tests/components/nut/conftest.py new file mode 100644 index 00000000000..bcf1cb4a99f --- /dev/null +++ b/tests/components/nut/conftest.py @@ -0,0 +1,5 @@ +"""NUT session fixtures.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.nut.util") diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3.json b/tests/components/nut/fixtures/EATON-EPDU-G3.json new file mode 100644 index 00000000000..cd6aeb4fd92 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3.json @@ -0,0 +1,539 @@ +{ + "ambient.contacts.1.status": "opened", + "ambient.contacts.2.status": "opened", + "ambient.count": "0", + "ambient.humidity": "29.90", + "ambient.humidity.high": "90", + "ambient.humidity.high.critical": "90", + "ambient.humidity.high.warning": "65", + "ambient.humidity.low": "10", + "ambient.humidity.low.critical": "10", + "ambient.humidity.low.warning": "20", + "ambient.humidity.status": "good", + "ambient.present": "yes", + "ambient.temperature": "28.9", + "ambient.temperature.high": "43.30", + "ambient.temperature.high.critical": "43.30", + "ambient.temperature.high.warning": "37.70", + "ambient.temperature.low": "5", + "ambient.temperature.low.critical": "5", + "ambient.temperature.low.warning": "10", + "ambient.temperature.status": "good", + "device.contact": "Contact Name", + "device.count": "1", + "device.description": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.location": "Device Location", + "device.macaddr": "00 00 00 FF FF FF ", + "device.mfr": "EATON", + "device.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.part": "EMA000-00", + "device.serial": "A000A00000", + "device.type": "pdu", + "driver.debug": "0", + "driver.flag.allow_killpower": "0", + "driver.name": "snmp-ups", + "driver.parameter.pollinterval": "2", + "driver.parameter.port": "eaton-pdu", + "driver.parameter.synchronous": "auto", + "driver.state": "dumping", + "driver.version": "2.8.2.882-882-g63d90ebcb", + "driver.version.data": "eaton_epdu MIB 0.69", + "driver.version.internal": "1.31", + "input.current": "4.30", + "input.current.high.critical": "16", + "input.current.high.warning": "12.80", + "input.current.low.warning": "0", + "input.current.nominal": "16", + "input.current.status": "good", + "input.feed.color": "0", + "input.feed.desc": "Feed A", + "input.frequency": "60", + "input.frequency.status": "good", + "input.L1.current": "4.30", + "input.L1.current.high.critical": "16", + "input.L1.current.high.warning": "12.80", + "input.L1.current.low.warning": "0", + "input.L1.current.nominal": "16", + "input.L1.current.status": "good", + "input.L1.load": "26", + "input.L1.power": "529", + "input.L1.realpower": "482", + "input.L1.voltage": "122.91", + "input.L1.voltage.high.critical": "140", + "input.L1.voltage.high.warning": "130", + "input.L1.voltage.low.critical": "90", + "input.L1.voltage.low.warning": "95", + "input.L1.voltage.status": "good", + "input.load": "26", + "input.phases": "1", + "input.power": "532", + "input.realpower": "482", + "input.realpower.nominal": "1920", + "input.voltage": "122.91", + "input.voltage.high.critical": "140", + "input.voltage.high.warning": "130", + "input.voltage.low.critical": "90", + "input.voltage.low.warning": "95", + "input.voltage.status": "good", + "outlet.1.current": "0", + "outlet.1.current.high.critical": "16", + "outlet.1.current.high.warning": "12.80", + "outlet.1.current.low.warning": "0", + "outlet.1.current.status": "good", + "outlet.1.delay.shutdown": "120", + "outlet.1.delay.start": "1", + "outlet.1.desc": "Outlet A1", + "outlet.1.groupid": "1", + "outlet.1.id": "1", + "outlet.1.name": "A1", + "outlet.1.power": "0", + "outlet.1.realpower": "0", + "outlet.1.status": "on", + "outlet.1.switchable": "yes", + "outlet.1.timer.shutdown": "-1", + "outlet.1.timer.start": "-1", + "outlet.1.type": "nema520", + "outlet.10.current": "0.26", + "outlet.10.current.high.critical": "16", + "outlet.10.current.high.warning": "12.80", + "outlet.10.current.low.warning": "0", + "outlet.10.current.status": "good", + "outlet.10.delay.shutdown": "120", + "outlet.10.delay.start": "10", + "outlet.10.desc": "Outlet A10", + "outlet.10.groupid": "1", + "outlet.10.id": "10", + "outlet.10.name": "A10", + "outlet.10.power": "32", + "outlet.10.realpower": "15", + "outlet.10.status": "on", + "outlet.10.switchable": "yes", + "outlet.10.timer.shutdown": "-1", + "outlet.10.timer.start": "-1", + "outlet.10.type": "nema520", + "outlet.11.current": "0.24", + "outlet.11.current.high.critical": "16", + "outlet.11.current.high.warning": "12.80", + "outlet.11.current.low.warning": "0", + "outlet.11.current.status": "good", + "outlet.11.delay.shutdown": "120", + "outlet.11.delay.start": "11", + "outlet.11.desc": "Outlet A11", + "outlet.11.groupid": "1", + "outlet.11.id": "11", + "outlet.11.name": "A11", + "outlet.11.power": "29", + "outlet.11.realpower": "22", + "outlet.11.status": "on", + "outlet.11.switchable": "yes", + "outlet.11.timer.shutdown": "-1", + "outlet.11.timer.start": "-1", + "outlet.11.type": "nema520", + "outlet.12.current": "0", + "outlet.12.current.high.critical": "16", + "outlet.12.current.high.warning": "12.80", + "outlet.12.current.low.warning": "0", + "outlet.12.current.status": "good", + "outlet.12.delay.shutdown": "120", + "outlet.12.delay.start": "12", + "outlet.12.desc": "Outlet A12", + "outlet.12.groupid": "1", + "outlet.12.id": "12", + "outlet.12.name": "A12", + "outlet.12.power": "0", + "outlet.12.realpower": "0", + "outlet.12.status": "on", + "outlet.12.switchable": "yes", + "outlet.12.timer.shutdown": "-1", + "outlet.12.timer.start": "-1", + "outlet.12.type": "nema520", + "outlet.13.current": "0.23", + "outlet.13.current.high.critical": "16", + "outlet.13.current.high.warning": "12.80", + "outlet.13.current.low.warning": "0", + "outlet.13.current.status": "good", + "outlet.13.delay.shutdown": "0", + "outlet.13.delay.start": "0", + "outlet.13.desc": "Outlet A13", + "outlet.13.groupid": "1", + "outlet.13.id": "0", + "outlet.13.name": "A13", + "outlet.13.power": "27", + "outlet.13.realpower": "9", + "outlet.13.status": "on", + "outlet.13.switchable": "yes", + "outlet.13.timer.shutdown": "-1", + "outlet.13.timer.start": "-1", + "outlet.13.type": "nema520", + "outlet.14.current": "0.10", + "outlet.14.current.high.critical": "16", + "outlet.14.current.high.warning": "12.80", + "outlet.14.current.low.warning": "0", + "outlet.14.current.status": "good", + "outlet.14.delay.shutdown": "120", + "outlet.14.delay.start": "14", + "outlet.14.desc": "Outlet A14", + "outlet.14.groupid": "1", + "outlet.14.id": "14", + "outlet.14.name": "A14", + "outlet.14.power": "12", + "outlet.14.realpower": "7", + "outlet.14.status": "on", + "outlet.14.switchable": "yes", + "outlet.14.timer.shutdown": "-1", + "outlet.14.timer.start": "-1", + "outlet.14.type": "nema520", + "outlet.15.current": "0.03", + "outlet.15.current.high.critical": "16", + "outlet.15.current.high.warning": "12.80", + "outlet.15.current.low.warning": "0", + "outlet.15.current.status": "good", + "outlet.15.delay.shutdown": "120", + "outlet.15.delay.start": "15", + "outlet.15.desc": "Outlet A15", + "outlet.15.groupid": "1", + "outlet.15.id": "15", + "outlet.15.name": "A15", + "outlet.15.power": "3", + "outlet.15.realpower": "1", + "outlet.15.status": "on", + "outlet.15.switchable": "yes", + "outlet.15.timer.shutdown": "-1", + "outlet.15.timer.start": "-1", + "outlet.15.type": "nema520", + "outlet.16.current": "0.04", + "outlet.16.current.high.critical": "16", + "outlet.16.current.high.warning": "12.80", + "outlet.16.current.low.warning": "0", + "outlet.16.current.status": "good", + "outlet.16.delay.shutdown": "120", + "outlet.16.delay.start": "16", + "outlet.16.desc": "Outlet A16", + "outlet.16.groupid": "1", + "outlet.16.id": "16", + "outlet.16.name": "A16", + "outlet.16.power": "4", + "outlet.16.realpower": "1", + "outlet.16.status": "on", + "outlet.16.switchable": "yes", + "outlet.16.timer.shutdown": "-1", + "outlet.16.timer.start": "-1", + "outlet.16.type": "nema520", + "outlet.17.current": "0.19", + "outlet.17.current.high.critical": "16", + "outlet.17.current.high.warning": "12.80", + "outlet.17.current.low.warning": "0", + "outlet.17.current.status": "good", + "outlet.17.delay.shutdown": "0", + "outlet.17.delay.start": "0", + "outlet.17.desc": "Outlet A17", + "outlet.17.groupid": "1", + "outlet.17.id": "0", + "outlet.17.name": "A17", + "outlet.17.power": "23", + "outlet.17.realpower": "5", + "outlet.17.status": "on", + "outlet.17.switchable": "yes", + "outlet.17.timer.shutdown": "-1", + "outlet.17.timer.start": "-1", + "outlet.17.type": "nema520", + "outlet.18.current": "0.35", + "outlet.18.current.high.critical": "16", + "outlet.18.current.high.warning": "12.80", + "outlet.18.current.low.warning": "0", + "outlet.18.current.status": "good", + "outlet.18.delay.shutdown": "0", + "outlet.18.delay.start": "0", + "outlet.18.desc": "Outlet A18", + "outlet.18.groupid": "1", + "outlet.18.id": "0", + "outlet.18.name": "A18", + "outlet.18.power": "42", + "outlet.18.realpower": "34", + "outlet.18.status": "on", + "outlet.18.switchable": "yes", + "outlet.18.timer.shutdown": "-1", + "outlet.18.timer.start": "-1", + "outlet.18.type": "nema520", + "outlet.19.current": "0.12", + "outlet.19.current.high.critical": "16", + "outlet.19.current.high.warning": "12.80", + "outlet.19.current.low.warning": "0", + "outlet.19.current.status": "good", + "outlet.19.delay.shutdown": "0", + "outlet.19.delay.start": "0", + "outlet.19.desc": "Outlet A19", + "outlet.19.groupid": "1", + "outlet.19.id": "0", + "outlet.19.name": "A19", + "outlet.19.power": "15", + "outlet.19.realpower": "6", + "outlet.19.status": "on", + "outlet.19.switchable": "yes", + "outlet.19.timer.shutdown": "-1", + "outlet.19.timer.start": "-1", + "outlet.19.type": "nema520", + "outlet.2.current": "0.39", + "outlet.2.current.high.critical": "16", + "outlet.2.current.high.warning": "12.80", + "outlet.2.current.low.warning": "0", + "outlet.2.current.status": "good", + "outlet.2.delay.shutdown": "120", + "outlet.2.delay.start": "2", + "outlet.2.desc": "Outlet A2", + "outlet.2.groupid": "1", + "outlet.2.id": "2", + "outlet.2.name": "A2", + "outlet.2.power": "47", + "outlet.2.realpower": "43", + "outlet.2.status": "on", + "outlet.2.switchable": "yes", + "outlet.2.timer.shutdown": "-1", + "outlet.2.timer.start": "-1", + "outlet.2.type": "nema520", + "outlet.20.current": "0", + "outlet.20.current.high.critical": "16", + "outlet.20.current.high.warning": "12.80", + "outlet.20.current.low.warning": "0", + "outlet.20.current.status": "good", + "outlet.20.delay.shutdown": "120", + "outlet.20.delay.start": "20", + "outlet.20.desc": "Outlet A20", + "outlet.20.groupid": "1", + "outlet.20.id": "20", + "outlet.20.name": "A20", + "outlet.20.power": "0", + "outlet.20.realpower": "0", + "outlet.20.status": "on", + "outlet.20.switchable": "yes", + "outlet.20.timer.shutdown": "-1", + "outlet.20.timer.start": "-1", + "outlet.20.type": "nema520", + "outlet.21.current": "0", + "outlet.21.current.high.critical": "16", + "outlet.21.current.high.warning": "12.80", + "outlet.21.current.low.warning": "0", + "outlet.21.current.status": "good", + "outlet.21.delay.shutdown": "120", + "outlet.21.delay.start": "21", + "outlet.21.desc": "Outlet A21", + "outlet.21.groupid": "1", + "outlet.21.id": "21", + "outlet.21.name": "A21", + "outlet.21.power": "0", + "outlet.21.realpower": "0", + "outlet.21.status": "on", + "outlet.21.switchable": "yes", + "outlet.21.timer.shutdown": "-1", + "outlet.21.timer.start": "-1", + "outlet.21.type": "nema520", + "outlet.22.current": "0", + "outlet.22.current.high.critical": "16", + "outlet.22.current.high.warning": "12.80", + "outlet.22.current.low.warning": "0", + "outlet.22.current.status": "good", + "outlet.22.delay.shutdown": "0", + "outlet.22.delay.start": "0", + "outlet.22.desc": "Outlet A22", + "outlet.22.groupid": "1", + "outlet.22.id": "0", + "outlet.22.name": "A22", + "outlet.22.power": "0", + "outlet.22.realpower": "0", + "outlet.22.status": "on", + "outlet.22.switchable": "yes", + "outlet.22.timer.shutdown": "-1", + "outlet.22.timer.start": "-1", + "outlet.22.type": "nema520", + "outlet.23.current": "0.34", + "outlet.23.current.high.critical": "16", + "outlet.23.current.high.warning": "12.80", + "outlet.23.current.low.warning": "0", + "outlet.23.current.status": "good", + "outlet.23.delay.shutdown": "120", + "outlet.23.delay.start": "23", + "outlet.23.desc": "Outlet A23", + "outlet.23.groupid": "1", + "outlet.23.id": "23", + "outlet.23.name": "A23", + "outlet.23.power": "41", + "outlet.23.realpower": "39", + "outlet.23.status": "on", + "outlet.23.switchable": "yes", + "outlet.23.timer.shutdown": "-1", + "outlet.23.timer.start": "-1", + "outlet.23.type": "nema520", + "outlet.24.current": "0.19", + "outlet.24.current.high.critical": "16", + "outlet.24.current.high.warning": "12.80", + "outlet.24.current.low.warning": "0", + "outlet.24.current.status": "good", + "outlet.24.delay.shutdown": "0", + "outlet.24.delay.start": "0", + "outlet.24.desc": "Outlet A24", + "outlet.24.groupid": "1", + "outlet.24.id": "0", + "outlet.24.name": "A24", + "outlet.24.power": "23", + "outlet.24.realpower": "11", + "outlet.24.status": "on", + "outlet.24.switchable": "yes", + "outlet.24.timer.shutdown": "-1", + "outlet.24.timer.start": "-1", + "outlet.24.type": "nema520", + "outlet.3.current": "0.46", + "outlet.3.current.high.critical": "16", + "outlet.3.current.high.warning": "12.80", + "outlet.3.current.low.warning": "0", + "outlet.3.current.status": "good", + "outlet.3.delay.shutdown": "120", + "outlet.3.delay.start": "3", + "outlet.3.desc": "Outlet A3", + "outlet.3.groupid": "1", + "outlet.3.id": "3", + "outlet.3.name": "A3", + "outlet.3.power": "56", + "outlet.3.realpower": "53", + "outlet.3.status": "on", + "outlet.3.switchable": "yes", + "outlet.3.timer.shutdown": "-1", + "outlet.3.timer.start": "-1", + "outlet.3.type": "nema520", + "outlet.4.current": "0.44", + "outlet.4.current.high.critical": "16", + "outlet.4.current.high.warning": "12.80", + "outlet.4.current.low.warning": "0", + "outlet.4.current.status": "good", + "outlet.4.delay.shutdown": "120", + "outlet.4.delay.start": "4", + "outlet.4.desc": "Outlet A4", + "outlet.4.groupid": "1", + "outlet.4.id": "4", + "outlet.4.name": "A4", + "outlet.4.power": "53", + "outlet.4.realpower": "48", + "outlet.4.status": "on", + "outlet.4.switchable": "yes", + "outlet.4.timer.shutdown": "-1", + "outlet.4.timer.start": "-1", + "outlet.4.type": "nema520", + "outlet.5.current": "0.43", + "outlet.5.current.high.critical": "16", + "outlet.5.current.high.warning": "12.80", + "outlet.5.current.low.warning": "0", + "outlet.5.current.status": "good", + "outlet.5.delay.shutdown": "120", + "outlet.5.delay.start": "5", + "outlet.5.desc": "Outlet A5", + "outlet.5.groupid": "1", + "outlet.5.id": "5", + "outlet.5.name": "A5", + "outlet.5.power": "52", + "outlet.5.realpower": "48", + "outlet.5.status": "on", + "outlet.5.switchable": "yes", + "outlet.5.timer.shutdown": "-1", + "outlet.5.timer.start": "-1", + "outlet.5.type": "nema520", + "outlet.6.current": "1.07", + "outlet.6.current.high.critical": "16", + "outlet.6.current.high.warning": "12.80", + "outlet.6.current.low.warning": "0", + "outlet.6.current.status": "good", + "outlet.6.delay.shutdown": "120", + "outlet.6.delay.start": "6", + "outlet.6.desc": "Outlet A6", + "outlet.6.groupid": "1", + "outlet.6.id": "6", + "outlet.6.name": "A6", + "outlet.6.power": "131", + "outlet.6.realpower": "118", + "outlet.6.status": "on", + "outlet.6.switchable": "yes", + "outlet.6.timer.shutdown": "-1", + "outlet.6.timer.start": "-1", + "outlet.6.type": "nema520", + "outlet.7.current": "0", + "outlet.7.current.high.critical": "16", + "outlet.7.current.high.warning": "12.80", + "outlet.7.current.low.warning": "0", + "outlet.7.current.status": "good", + "outlet.7.delay.shutdown": "120", + "outlet.7.delay.start": "7", + "outlet.7.desc": "Outlet A7", + "outlet.7.groupid": "1", + "outlet.7.id": "7", + "outlet.7.name": "A7", + "outlet.7.power": "0", + "outlet.7.realpower": "0", + "outlet.7.status": "on", + "outlet.7.switchable": "yes", + "outlet.7.timer.shutdown": "-1", + "outlet.7.timer.start": "-1", + "outlet.7.type": "nema520", + "outlet.8.current": "0", + "outlet.8.current.high.critical": "16", + "outlet.8.current.high.warning": "12.80", + "outlet.8.current.low.warning": "0", + "outlet.8.current.status": "good", + "outlet.8.delay.shutdown": "120", + "outlet.8.delay.start": "8", + "outlet.8.desc": "Outlet A8", + "outlet.8.groupid": "1", + "outlet.8.id": "8", + "outlet.8.name": "A8", + "outlet.8.power": "0", + "outlet.8.realpower": "0", + "outlet.8.status": "on", + "outlet.8.switchable": "yes", + "outlet.8.timer.shutdown": "-1", + "outlet.8.timer.start": "-1", + "outlet.8.type": "nema520", + "outlet.9.current": "0", + "outlet.9.current.high.critical": "16", + "outlet.9.current.high.warning": "12.80", + "outlet.9.current.low.warning": "0", + "outlet.9.current.status": "good", + "outlet.9.delay.shutdown": "120", + "outlet.9.delay.start": "9", + "outlet.9.desc": "Outlet A9", + "outlet.9.groupid": "1", + "outlet.9.id": "9", + "outlet.9.name": "A9", + "outlet.9.power": "0", + "outlet.9.realpower": "0", + "outlet.9.status": "on", + "outlet.9.switchable": "yes", + "outlet.9.timer.shutdown": "-1", + "outlet.9.timer.start": "-1", + "outlet.9.type": "nema520", + "outlet.count": "24", + "outlet.current": "43.05", + "outlet.desc": "All outlets", + "outlet.frequency": "60", + "outlet.group.1.color": "16051527", + "outlet.group.1.count": "24", + "outlet.group.1.desc": "Section A", + "outlet.group.1.id": "1", + "outlet.group.1.input": "1", + "outlet.group.1.name": "A", + "outlet.group.1.phase": "1", + "outlet.group.1.status": "on", + "outlet.group.1.type": "outlet-section", + "outlet.group.1.voltage": "122.83", + "outlet.group.1.voltage.high.critical": "140", + "outlet.group.1.voltage.high.warning": "130", + "outlet.group.1.voltage.low.critical": "90", + "outlet.group.1.voltage.low.warning": "95", + "outlet.group.1.voltage.status": "good", + "outlet.group.count": "1", + "outlet.id": "0", + "outlet.switchable": "yes", + "outlet.voltage": "122.91", + "ups.firmware": "05.01.0002", + "ups.mfr": "EATON", + "ups.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "ups.serial": "A000A00000", + "ups.status": "", + "ups.type": "pdu" +} diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 537b6aba5ac..ed9c87f2f90 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -20,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .util import _get_mock_nutclient @@ -38,7 +38,7 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index d5d85daa336..0585696cef2 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -1,12 +1,19 @@ """Test init of Nut integration.""" +from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -147,3 +154,44 @@ async def test_device_location(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.suggested_area == mock_device_location + + +async def test_update_options(hass: HomeAssistant) -> None: + """Test update options triggers reload.""" + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ups.status": "OL"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "mock", + CONF_PASSWORD: "somepassword", + CONF_PORT: "mock", + CONF_USERNAME: "someuser", + }, + options={ + "device_options": { + "fake_option": "fake_option_value", + }, + }, + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_options = deepcopy(dict(mock_config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index afe57631910..eb171c39011 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -5,17 +5,23 @@ from unittest.mock import patch import pytest from homeassistant.components.nut.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import _get_mock_nutclient, async_init_integration +from .util import ( + _get_mock_nutclient, + _test_sensor_and_attributes, + async_init_integration, +) from tests.common import MockConfigEntry @@ -32,7 +38,7 @@ from tests.common import MockConfigEntry "blazer_usb", ], ) -async def test_devices( +async def test_ups_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str ) -> None: """Test creation of device sensors.""" @@ -67,7 +73,7 @@ async def test_devices( ), ], ) -async def test_devices_with_unique_ids( +async def test_ups_devices_with_unique_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, unique_id: str ) -> None: """Test creation of device sensors with unique ids.""" @@ -92,6 +98,65 @@ async def test_devices_with_unique_ids( ) +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_pdu_devices_with_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test creation of device sensors with unique ids.""" + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}input.voltage", + device_id="sensor.ups1_input_voltage", + state_value="122.91", + expected_attributes={ + "device_class": SensorDeviceClass.VOLTAGE, + "state_class": SensorStateClass.MEASUREMENT, + "friendly_name": "Ups1 Input voltage", + "unit_of_measurement": UnitOfElectricPotential.VOLT, + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.humidity.status", + device_id="sensor.ups1_ambient_humidity_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient humidity status", + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.temperature.status", + device_id="sensor.ups1_ambient_temperature_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient temperature status", + }, + ) + + async def test_state_sensors(hass: HomeAssistant) -> None: """Test creation of status display sensors.""" entry = MockConfigEntry( diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index b6c9cffd390..bd82ffdd6b4 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_fixture @@ -79,3 +80,29 @@ async def async_init_integration( await hass.async_block_till_done() return entry + + +async def _test_sensor_and_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id: str, + device_id: str, + state_value: str, + expected_attributes: dict, +) -> None: + """Test creation of device sensors with unique ids.""" + + await async_init_integration(hass, model) + entry = entity_registry.async_get(device_id) + assert entry + assert entry.unique_id == unique_id + + state = hass.states.get(device_id) + assert state.state == state_value + + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == attr for key, attr in expected_attributes.items() + ) diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index 383bed0e106..d9ce6f15a4d 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 84b74a26f0d..8201c26739c 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,6 +262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -307,6 +313,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -357,6 +364,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -407,6 +415,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -509,6 +519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -559,6 +570,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py index b88f0a5c874..7b483514dcf 100644 --- a/tests/components/obihai/__init__.py +++ b/tests/components/obihai/__init__.py @@ -1,7 +1,7 @@ """Tests for the Obihai Integration.""" -from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo USER_INPUT = { CONF_HOST: "10.10.10.30", @@ -9,7 +9,7 @@ USER_INPUT = { CONF_USERNAME: "admin", } -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="obi200", ip="192.168.1.100", macaddress="9cadef000000", diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e0696486718..d7d7e43e99c 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -6,10 +6,11 @@ from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.octoprint.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -183,7 +184,7 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -252,7 +253,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ @@ -525,7 +526,7 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -551,7 +552,7 @@ async def test_duplicate_ssdp_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={ diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 9a196a5b231..01cc668ae32 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -54,7 +54,10 @@ def mock_client(): client.status = ChargerStatus.CHARGING client.power = ChargerPower(0, 0, 0, 0) + client.target_soc = 50 + client.target_time = (8, 0) client.battery = 80 + client.preconditioning = 15 client.serial = "chargerid" client.ct_connected = True client.energy = 1000 diff --git a/tests/components/ohme/snapshots/test_button.ambr b/tests/components/ohme/snapshots/test_button.ambr index 32de16208f4..b276e8c3c42 100644 --- a/tests/components/ohme/snapshots/test_button.ambr +++ b/tests/components/ohme/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index e3ed339b78a..ccf09f546cf 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr new file mode 100644 index 00000000000..69e18d0b2a7 --- /dev/null +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_numbers[number.ohme_home_pro_preconditioning_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ohme_home_pro_preconditioning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning duration', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preconditioning_duration', + 'unique_id': 'chargerid_preconditioning_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.ohme_home_pro_preconditioning_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Preconditioning duration', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.ohme_home_pro_preconditioning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_numbers[number.ohme_home_pro_target_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ohme_home_pro_target_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target percentage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_percentage', + 'unique_id': 'chargerid_target_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.ohme_home_pro_target_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Target percentage', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.ohme_home_pro_target_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr new file mode 100644 index 00000000000..8eec0556889 --- /dev/null +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_selects[select.ohme_home_pro_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart_charge', + 'max_charge', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ohme_home_pro_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'chargerid_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.ohme_home_pro_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge mode', + 'options': list([ + 'smart_charge', + 'max_charge', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'select.ohme_home_pro_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index 6415d720419..9cef4bfffd9 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_sensors[sensor.ohme_home_pro_charge_slots-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge slots', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slot_list', + 'unique_id': 'chargerid_slot_list', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_charge_slots-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge slots', + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.ohme_home_pro_ct_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +209,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,9 +269,11 @@ 'charging', 'plugged_in', 'paused', + 'finished', ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +311,7 @@ 'charging', 'plugged_in', 'paused', + 'finished', ]), }), 'context': , @@ -275,6 +329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -319,3 +374,55 @@ 'state': '80', }) # --- +# name: test_sensors[sensor.ohme_home_pro_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Ohme Home Pro Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr index 76066b6e658..49bf5d5709a 100644 --- a/tests/components/ohme/snapshots/test_switch.ambr +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_time.ambr b/tests/components/ohme/snapshots/test_time.ambr new file mode 100644 index 00000000000..8c85fc2298e --- /dev/null +++ b/tests/components/ohme/snapshots/test_time.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_time[time.ohme_home_pro_target_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': None, + 'entity_id': 'time.ohme_home_pro_target_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target time', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_time', + 'unique_id': 'chargerid_target_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_time[time.ohme_home_pro_target_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Target time', + }), + 'context': , + 'entity_id': 'time.ohme_home_pro_target_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- diff --git a/tests/components/ohme/test_number.py b/tests/components/ohme/test_number.py new file mode 100644 index 00000000000..9cfce2a850f --- /dev/null +++ b/tests/components/ohme/test_number.py @@ -0,0 +1,55 @@ +"""Tests for numbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_number( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the number set.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_VALUE: 100, + }, + target={ + ATTR_ENTITY_ID: "number.ohme_home_pro_target_percentage", + }, + blocking=True, + ) + + assert len(mock_client.async_set_target.mock_calls) == 1 diff --git a/tests/components/ohme/test_select.py b/tests/components/ohme/test_select.py new file mode 100644 index 00000000000..5aeebc1f477 --- /dev/null +++ b/tests/components/ohme/test_select.py @@ -0,0 +1,72 @@ +"""Tests for selects.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from ohme import ChargerMode +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_selects( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme selects.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test selecting an option in the Ohme select entity.""" + mock_client.mode = ChargerMode.SMART_CHARGE + mock_client.async_set_mode = AsyncMock() + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.ohme_home_pro_charge_mode") + assert state is not None + assert state.state == "smart_charge" + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.ohme_home_pro_charge_mode", + "option": "max_charge", + }, + blocking=True, + ) + + mock_client.async_set_mode.assert_called_once_with("max_charge") + assert state.state == "smart_charge" + + +async def test_select_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that the select entity shows as unavailable when no mode is set.""" + mock_client.mode = None + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.ohme_home_pro_charge_mode") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/ohme/test_time.py b/tests/components/ohme/test_time.py new file mode 100644 index 00000000000..0562dfa124c --- /dev/null +++ b/tests/components/ohme/test_time.py @@ -0,0 +1,55 @@ +"""Tests for time.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_time( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme sensors.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.TIME]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the time set.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ + ATTR_TIME: "00:00:00", + }, + target={ + ATTR_ENTITY_ID: "time.ohme_home_pro_target_time", + }, + blocking=True, + ) + + assert len(mock_client.async_set_target.mock_calls) == 1 diff --git a/tests/components/ollama/snapshots/test_conversation.ambr b/tests/components/ollama/snapshots/test_conversation.ambr index e4dd7cd00bb..de414019317 100644 --- a/tests/components/ollama/snapshots/test_conversation.ambr +++ b/tests/components/ollama/snapshots/test_conversation.ambr @@ -1,7 +1,8 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'continue_conversation': False, + 'conversation_id': '1234', 'response': IntentResponse( card=dict({ }), @@ -20,7 +21,7 @@ speech=dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Error preparing LLM API: API non-existing not found', + 'speech': 'Error preparing LLM API', }), }), speech_slots=dict({ diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 3202b42d9b3..db641ba703b 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -1,5 +1,6 @@ """Tests for the Ollama integration.""" +from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -18,6 +19,21 @@ from homeassistant.helpers import intent, llm from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_ulid_tools(): + """Mock generated ULIDs for tool calls.""" + with patch("homeassistant.helpers.llm.ulid_now", return_value="mock-tool-call"): + yield + + +async def stream_generator(response: dict | list[dict]) -> AsyncGenerator[dict]: + """Generate a response from the assistant.""" + if not isinstance(response, list): + response = [response] + for msg in response: + yield msg + + @pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) async def test_chat( hass: HomeAssistant, @@ -35,7 +51,9 @@ async def test_chat( with patch( "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, + return_value=stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ), ) as mock_chat: result = await conversation.async_converse( hass, @@ -55,9 +73,9 @@ async def test_chat( Message(role="user", content="test message"), ] - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) assert result.response.speech["plain"]["speech"] == "test response" # Test Conversation tracing @@ -74,6 +92,53 @@ async def test_chat( assert "Current time is" in detail_event["data"]["messages"][0]["content"] +async def test_chat_stream( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test chat messages are assembled across streamed responses.""" + + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with patch( + "ollama.AsyncClient.chat", + return_value=stream_generator( + [ + {"message": {"role": "assistant", "content": "test "}}, + { + "message": {"role": "assistant", "content": "response"}, + "done": True, + "done_reason": "stop", + }, + ], + ), + ) as mock_chat: + result = await conversation.async_converse( + hass, + "test message", + None, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + assert mock_chat.call_count == 1 + args = mock_chat.call_args.kwargs + prompt = args["messages"][0]["content"] + + assert args["model"] == "test model" + assert args["messages"] == [ + Message(role="system", content=prompt), + Message(role="user", content="test message"), + ] + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) + assert result.response.speech["plain"]["speech"] == "test response" + + async def test_template_variables( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -96,7 +161,9 @@ async def test_template_variables( patch("ollama.AsyncClient.list"), patch( "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, + return_value=stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ), ) as mock_chat, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): @@ -106,9 +173,9 @@ async def test_template_variables( hass, "hello", None, context, agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] @@ -163,26 +230,30 @@ async def test_function_call( def completion_result(*args, messages, **kwargs): for message in messages: if message["role"] == "tool": - return { - "message": { - "role": "assistant", - "content": "I have successfully called the function", - } - } - - return { - "message": { - "role": "assistant", - "tool_calls": [ + return stream_generator( { - "function": { - "name": "test_tool", - "arguments": tool_args, + "message": { + "role": "assistant", + "content": "I have successfully called the function", } } - ], + ) + + return stream_generator( + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "function": { + "name": "test_tool", + "arguments": tool_args, + } + } + ], + } } - } + ) with patch( "ollama.AsyncClient.chat", @@ -205,6 +276,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args=expected_tool_args, ), @@ -243,26 +315,30 @@ async def test_function_exception( def completion_result(*args, messages, **kwargs): for message in messages: if message["role"] == "tool": - return { - "message": { - "role": "assistant", - "content": "There was an error calling the function", - } - } - - return { - "message": { - "role": "assistant", - "tool_calls": [ + return stream_generator( { - "function": { - "name": "test_tool", - "arguments": {"param1": "test_value"}, + "message": { + "role": "assistant", + "content": "There was an error calling the function", } } - ], + ) + + return stream_generator( + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "function": { + "name": "test_tool", + "arguments": {"param1": "test_value"}, + } + } + ], + } } - } + ) with patch( "ollama.AsyncClient.chat", @@ -285,6 +361,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -316,7 +393,11 @@ async def test_unknown_hass_api( await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + "1234", + Context(), + agent_id=mock_config_entry.entry_id, ) assert result == snapshot @@ -331,7 +412,9 @@ async def test_message_history_trimming( def response(*args, **kwargs) -> dict: nonlocal response_idx response_idx += 1 - return {"message": {"role": "assistant", "content": f"response {response_idx}"}} + return stream_generator( + {"message": {"role": "assistant", "content": f"response {response_idx}"}} + ) with patch( "ollama.AsyncClient.chat", @@ -341,7 +424,7 @@ async def test_message_history_trimming( for i in range(5): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id="1234", context=Context(), agent_id=mock_config_entry.entry_id, @@ -419,70 +502,19 @@ async def test_message_history_trimming( assert args[4].kwargs["messages"][5]["content"] == "message 5" -async def test_message_history_pruning( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that old message histories are pruned.""" - with patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ): - # Create 3 different message histories - conversation_ids: list[str] = [] - for i in range(3): - result = await conversation.async_converse( - hass, - f"message {i+1}", - conversation_id=None, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert isinstance(result.conversation_id, str) - conversation_ids.append(result.conversation_id) - - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id - ) - assert len(agent._history) == 3 - assert agent._history.keys() == set(conversation_ids) - - # Modify the timestamps of the first 2 histories so they will be pruned - # on the next cycle. - for conversation_id in conversation_ids[:2]: - # Move back 2 hours - agent._history[conversation_id].timestamp -= 2 * 60 * 60 - - # Next cycle - result = await conversation.async_converse( - hass, - "test message", - conversation_id=None, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - - # Only the most recent histories should remain - assert len(agent._history) == 2 - assert conversation_ids[-1] in agent._history - assert result.conversation_id in agent._history - - async def test_message_history_unlimited( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: """Test that message history is not trimmed when max_history = 0.""" conversation_id = "1234" + + def stream(*args, **kwargs) -> AsyncGenerator[dict]: + return stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ) + with ( - patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ), + patch("ollama.AsyncClient.chat", side_effect=stream) as mock_chat, ): hass.config_entries.async_update_entry( mock_config_entry, options={ollama.CONF_MAX_HISTORY: 0} @@ -490,7 +522,7 @@ async def test_message_history_unlimited( for i in range(100): result = await conversation.async_converse( hass, - f"message {i+1}", + f"message {i + 1}", conversation_id=conversation_id, context=Context(), agent_id=mock_config_entry.entry_id, @@ -499,13 +531,13 @@ async def test_message_history_unlimited( result.response.response_type == intent.IntentResponseType.ACTION_DONE ), result - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id + args = mock_chat.call_args_list + assert len(args) == 100 + recorded_messages = args[-1].kwargs["messages"] + message_count = sum( + (message["role"] == "user") for message in recorded_messages ) - - assert len(agent._history) == 1 - assert conversation_id in agent._history - assert agent._history[conversation_id].num_user_messages == 100 + assert message_count == 100 async def test_error_handling( @@ -599,7 +631,9 @@ async def test_options( """Test that options are passed correctly to ollama client.""" with patch( "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, + return_value=stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ), ) as mock_chat: await conversation.async_converse( hass, diff --git a/tests/components/omnilogic/snapshots/test_sensor.ambr b/tests/components/omnilogic/snapshots/test_sensor.ambr index a4ea7f02a03..b6eb07dbe26 100644 --- a/tests/components/omnilogic/snapshots/test_sensor.ambr +++ b/tests/components/omnilogic/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/omnilogic/snapshots/test_switch.ambr b/tests/components/omnilogic/snapshots/test_switch.ambr index a5d77f1adcf..cc1a2e226fc 100644 --- a/tests/components/omnilogic/snapshots/test_switch.ambr +++ b/tests/components/omnilogic/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr new file mode 100644 index 00000000000..2d084bd9ade --- /dev/null +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_onboarding_backup_info + dict({ + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': True, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'abc123', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'size': 0, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'unknown_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), + ]), + 'last_non_idle_event': None, + 'state': 'idle', + }) +# --- diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 35f6b7d739c..b7189bda6cc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,16 +3,20 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus +from io import StringIO import os from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest +from syrupy import SnapshotAssertion -from homeassistant.components import onboarding +from homeassistant.components import backup, onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import mock_storage @@ -526,32 +530,6 @@ async def test_onboarding_core_sets_up_radio_browser( assert len(hass.config_entries.async_entries("radio_browser")) == 1 -async def test_onboarding_core_sets_up_rpi_power( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - rpi, - mock_default_integrations, -) -> None: - """Test that the core step sets up rpi_power on RPi.""" - mock_storage(hass_storage, {"done": [const.STEP_USER]}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - resp = await client.post("/api/onboarding/core_config") - - assert resp.status == 200 - - await hass.async_block_till_done() - - rpi_power_state = hass.states.get("binary_sensor.rpi_power_status") - assert rpi_power_state - - async def test_onboarding_core_no_rpi_power( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -649,12 +627,28 @@ async def test_onboarding_installation_type( assert resp_content["installation_type"] == "Home Assistant Core" -async def test_onboarding_installation_type_after_done( +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "installation_type", {}), + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], ) -> None: - """Test raising for installation type after onboarding.""" + """Test raising after onboarding.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) @@ -662,7 +656,7 @@ async def test_onboarding_installation_type_after_done( client = await hass_client() - resp = await client.get("/api/onboarding/installation_type") + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) assert resp.status == 401 @@ -726,3 +720,350 @@ async def test_complete_onboarding( listener_3 = Mock() onboarding.async_add_listener(hass, listener_3) listener_3.assert_called_once_with() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 500 + assert await resp.json() == {"code": "backup_disabled"} + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['agent_id']" + }, + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['backup_id']" + }, + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + { + "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" + }, + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + { + "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" + }, + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + {"code": "incorrect_password"}, + 1, + ), + # Home Assistant error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + HomeAssistantError("Boom!"), + 400, + {"code": "restore_failed", "message": "Boom!"}, + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_json: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == expected_json + assert len(mock_restore.mock_calls) == restore_calls + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Unexpected error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + Exception("Boom!"), + 500, + "500 Internal Server Error", + 1, + ), + ], +) +async def test_onboarding_backup_restore_unexpected_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert (await resp.content.read()).decode().startswith(expected_message) + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) diff --git a/tests/components/ondilo_ico/fixtures/pool2.json b/tests/components/ondilo_ico/fixtures/pool2.json index da0cb62d484..24e72b469f0 100644 --- a/tests/components/ondilo_ico/fixtures/pool2.json +++ b/tests/components/ondilo_ico/fixtures/pool2.json @@ -15,5 +15,5 @@ "latitude": 48.861783, "longitude": 2.337421 }, - "updated_at": "2024-01-01T01:00:00+0000" + "updated_at": "2024-01-01T01:05:00+0000" } diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 44008ac907e..07e56a78fae 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr index 56e30cd904a..84a2d3da4cb 100644 --- a/tests/components/ondilo_ico/snapshots/test_sensor.ambr +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -309,6 +315,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -360,6 +367,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -411,6 +419,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +470,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -511,6 +521,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -561,6 +572,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -611,6 +623,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -661,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ondilo_ico/test_init.py b/tests/components/ondilo_ico/test_init.py index 67f68f27b3e..58b1e27987d 100644 --- a/tests/components/ondilo_ico/test_init.py +++ b/tests/components/ondilo_ico/test_init.py @@ -1,8 +1,10 @@ """Test Ondilo ICO initialization.""" +from datetime import datetime, timedelta from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from ondilo import OndiloError import pytest from syrupy import SnapshotAssertion @@ -13,7 +15,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_devices( @@ -63,6 +65,7 @@ async def test_get_pools_error( async def test_init_with_no_ico_attached( hass: HomeAssistant, mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, pool1: dict[str, Any], ) -> None: @@ -73,14 +76,104 @@ async def test_init_with_no_ico_attached( mock_ondilo_client.get_ICO_details.return_value = None await setup_integration(hass, config_entry, mock_ondilo_client) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + # No devices should be created + assert len(device_entries) == 0 # No sensor should be created assert len(hass.states.async_all()) == 0 # We should not have tried to retrieve pool measures mock_ondilo_client.get_last_pool_measures.assert_not_called() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("api", ["get_ICO_details", "get_last_pool_measures"]) +async def test_adding_pool_after_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + pool1: dict[str, Any], + two_pools: list[dict[str, Any]], + ico_details1: dict[str, Any], + ico_details2: dict[str, Any], +) -> None: + """Test adding one pool after integration setup.""" + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.return_value = ico_details1 + + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # One pool is created with 7 entities. + assert len(device_entries) == 1 + assert len(hass.states.async_all()) == 7 + + mock_ondilo_client.get_pools.return_value = two_pools + mock_ondilo_client.get_ICO_details.return_value = ico_details2 + + # Trigger a refresh of the pools coordinator. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # Two pool have been created with 7 entities each. + assert len(device_entries) == 2 + assert len(hass.states.async_all()) == 14 + + +async def test_removing_pool_after_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + pool1: dict[str, Any], + ico_details1: dict[str, Any], +) -> None: + """Test removing one pool after integration setup.""" + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # Two pools are created with 7 entities each. + assert len(device_entries) == 2 + assert len(hass.states.async_all()) == 14 + + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.return_value = ico_details1 + + # Trigger a refresh of the pools coordinator. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # One pool is left with 7 entities. + assert len(device_entries) == 1 + assert len(hass.states.async_all()) == 7 + + +@pytest.mark.parametrize( + ("api", "devices", "config_entry_state"), + [ + ("get_ICO_details", 0, ConfigEntryState.SETUP_RETRY), + ("get_last_pool_measures", 1, ConfigEntryState.LOADED), + ], +) async def test_details_error_all_pools( hass: HomeAssistant, mock_ondilo_client: MagicMock, @@ -88,6 +181,8 @@ async def test_details_error_all_pools( config_entry: MockConfigEntry, pool1: dict[str, Any], api: str, + devices: int, + config_entry_state: ConfigEntryState, ) -> None: """Test details and measures error for all pools.""" mock_ondilo_client.get_pools.return_value = pool1 @@ -100,8 +195,8 @@ async def test_details_error_all_pools( device_registry, config_entry.entry_id ) - assert not device_entries - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(device_entries) == devices + assert config_entry.state is config_entry_state async def test_details_error_one_pool( @@ -131,12 +226,15 @@ async def test_details_error_one_pool( async def test_measures_error_one_pool( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_ondilo_client: MagicMock, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, last_measures: list[dict[str, Any]], ) -> None: """Test measures error for one pool and success for the other.""" + entity_id_1 = "sensor.pool_1_temperature" + entity_id_2 = "sensor.pool_2_temperature" mock_ondilo_client.get_last_pool_measures.side_effect = [ OndiloError( 404, @@ -151,4 +249,170 @@ async def test_measures_error_one_pool( device_registry, config_entry.entry_id ) - assert len(device_entries) == 1 + assert len(device_entries) == 2 + # One pool returned an error, the other is ok. + # 7 entities are created for the second pool. + assert len(hass.states.async_all()) == 7 + assert hass.states.get(entity_id_1) is None + assert hass.states.get(entity_id_2) is not None + + # All pools now return measures. + mock_ondilo_client.get_last_pool_measures.side_effect = None + + # Move time to next pools coordinator refresh. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 2 + # 14 entities in total, 7 entities per pool. + assert len(hass.states.async_all()) == 14 + assert hass.states.get(entity_id_1) is not None + assert hass.states.get(entity_id_2) is not None + + +async def test_measures_scheduling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test refresh scheduling of measures coordinator.""" + # Move time to 10 min after pool 1 was updated and 5 min after pool 2 was updated. + freezer.move_to("2024-01-01T01:10:00+00:00") + entity_id_1 = "sensor.pool_1_temperature" + entity_id_2 = "sensor.pool_2_temperature" + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # Two pools are created with 7 entities each. + assert len(device_entries) == 2 + assert len(hass.states.async_all()) == 14 + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + + # Tick time by 20 min. + # The measures coordinators for both pools should not have been refreshed again. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + + # Move time to 65 min after pool 1 was last updated. + # This is 5 min after we expect pool 1 to be updated again. + # The measures coordinator for pool 1 should refresh at this time. + # The measures coordinator for pool 2 should not have been refreshed again. + # The pools coordinator has updated the last update time + # of the pools to a stale time that is already passed. + freezer.move_to("2024-01-01T02:05:00+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + + # Tick time by 5 min. + # The measures coordinator for pool 1 should not have been refreshed again. + # The measures coordinator for pool 2 should refresh at this time. + # The pools coordinator has updated the last update time + # of the pools to a stale time that is already passed. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:10:00+00:00") + + # Tick time by 55 min. + # The measures coordinator for pool 1 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 1. + freezer.tick(timedelta(minutes=55)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:10:00+00:00") + + # Tick time by 5 min. + # The measures coordinator for pool 2 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 2. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:10:00+00:00") + + # Set an error on the pools coordinator endpoint. + # This will cause the pools coordinator to not update the next refresh. + # This should cause the measures coordinators to keep the 1 hour cadence. + mock_ondilo_client.get_pools.side_effect = OndiloError( + 502, + ( + " 502 Bad Gateway " + "

502 Bad Gateway

" + ), + ) + + # Tick time by 55 min. + # The measures coordinator for pool 1 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 1. + freezer.tick(timedelta(minutes=55)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T04:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:10:00+00:00") + + # Tick time by 5 min. + # The measures coordinator for pool 2 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 2. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T04:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T04:10:00+00:00") diff --git a/tests/components/onedrive/__init__.py b/tests/components/onedrive/__init__.py new file mode 100644 index 00000000000..0bafe37775b --- /dev/null +++ b/tests/components/onedrive/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the OneDrive integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OneDrive integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py new file mode 100644 index 00000000000..74232f2cc39 --- /dev/null +++ b/tests/components/onedrive/conftest.py @@ -0,0 +1,269 @@ +"""Fixtures for OneDrive tests.""" + +from collections.abc import AsyncIterator, Generator +from html import escape +from json import dumps +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from onedrive_personal_sdk.const import DriveState, DriveType +from onedrive_personal_sdk.models.items import ( + AppRoot, + Drive, + DriveQuota, + File, + Folder, + Hashes, + IdentitySet, + ItemParentReference, + User, +) +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, IDENTITY_SET, INSTANCE_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + CONF_FOLDER_NAME: "backups_123", + CONF_FOLDER_ID: "my_folder_id", + }, + unique_id="mock_drive_id", + minor_version=2, + ) + + +@pytest.fixture +def mock_onedrive_client_init() -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.OneDriveClient", + autospec=True, + ) as onedrive_client, + patch( + "homeassistant.components.onedrive.OneDriveClient", + new=onedrive_client, + ), + ): + yield onedrive_client + + +@pytest.fixture +def mock_approot() -> AppRoot: + """Return a mocked approot.""" + return AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) + ), + ) + + +@pytest.fixture +def mock_drive() -> Drive: + """Return a mocked drive.""" + return Drive( + id="mock_drive_id", + name="My Drive", + drive_type=DriveType.PERSONAL, + owner=IDENTITY_SET, + quota=DriveQuota( + deleted=5, + remaining=805306368, + state=DriveState.NEARING, + total=5368709120, + used=4250000000, + ), + ) + + +@pytest.fixture +def mock_folder() -> Folder: + """Return a mocked backup folder.""" + return Folder( + id="my_folder_id", + name="name", + size=0, + child_count=0, + description="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ), + ), + ) + + +@pytest.fixture +def mock_backup_file() -> File: + """Return a mocked backup file.""" + return File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + created_by=IDENTITY_SET, + ) + + +@pytest.fixture +def mock_metadata_file() -> File: + """Return a mocked metadata file.""" + return File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape( + dumps( + { + "metadata_version": 2, + "backup_id": "23e64aec", + "backup_file_id": "id", + } + ) + ), + created_by=IDENTITY_SET, + ) + + +@pytest.fixture(autouse=True) +def mock_onedrive_client( + mock_onedrive_client_init: MagicMock, + mock_approot: AppRoot, + mock_drive: Drive, + mock_folder: Folder, + mock_backup_file: File, + mock_metadata_file: File, +) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + client = mock_onedrive_client_init.return_value + client.get_approot.return_value = mock_approot + client.create_folder.return_value = mock_folder + client.list_drive_items.return_value = [mock_backup_file, mock_metadata_file] + client.get_drive_item.return_value = mock_folder + client.upload_file.return_value = mock_metadata_file + + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" + + async def read(self) -> bytes: + return dumps(BACKUP_METADATA).encode() + + client.download_drive_item.return_value = MockStreamReader() + client.get_drive.return_value = mock_drive + return client + + +@pytest.fixture +def mock_large_file_upload_client(mock_backup_file: File) -> Generator[AsyncMock]: + """Return a mocked LargeFileUploadClient upload.""" + with patch( + "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" + ) as mock_upload: + mock_upload.return_value = mock_backup_file + yield mock_upload + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onedrive.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock the instance ID.""" + with ( + patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value=INSTANCE_ID, + ) as mock_instance_id, + patch( + "homeassistant.components.onedrive.config_flow.async_get_instance_id", + new=mock_instance_id, + ), + ): + yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py new file mode 100644 index 00000000000..4e67c358179 --- /dev/null +++ b/tests/components/onedrive/const.py @@ -0,0 +1,31 @@ +"""Consts for OneDrive tests.""" + +from onedrive_personal_sdk.models.items import IdentitySet, User + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, +} + +INSTANCE_ID = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0" + +IDENTITY_SET = IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) +) diff --git a/tests/components/onedrive/snapshots/test_diagnostics.ambr b/tests/components/onedrive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..827b9397313 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'auth_implementation': 'onedrive', + 'folder_id': 'my_folder_id', + 'folder_name': 'name', + 'token': '**REDACTED**', + }), + 'drive': dict({ + 'drive_type': 'personal', + 'id': 'mock_drive_id', + 'name': 'My Drive', + 'owner': dict({ + 'application': None, + 'user': dict({ + 'display_name': '**REDACTED**', + 'email': '**REDACTED**', + 'id': 'id', + }), + }), + 'quota': dict({ + 'deleted': 5, + 'remaining': 805306368, + 'state': 'nearing', + 'total': 5368709120, + 'used': 4250000000, + }), + }), + }) +# --- diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr new file mode 100644 index 00000000000..9b2ed7e4d94 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://onedrive.live.com/?id=root&cid=mock_drive_id', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onedrive', + 'mock_drive_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Microsoft', + 'model': 'OneDrive Personal', + 'model_id': None, + 'name': 'My Drive', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/onedrive/snapshots/test_sensor.ambr b/tests/components/onedrive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..742c069f206 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_sensor.ambr @@ -0,0 +1,227 @@ +# serializer version: 1 +# name: test_sensors[sensor.my_drive_drive_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_drive_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Drive state', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state', + 'unique_id': 'mock_drive_id_drive_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.my_drive_drive_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My Drive Drive state', + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_drive_drive_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'nearing', + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_size', + 'unique_id': 'mock_drive_id_remaining_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Remaining storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.75', + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total available storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_size', + 'unique_id': 'mock_drive_id_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Total available storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_used_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'used_size', + 'unique_id': 'mock_drive_id_used_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Used storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_used_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.95812094211578', + }) +# --- diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py new file mode 100644 index 00000000000..a81eb03a51c --- /dev/null +++ b/tests/components/onedrive/test_backup.py @@ -0,0 +1,391 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import Mock, patch + +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import File +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.onedrive.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.onedrive.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive and backup integrations.""" + async_initialize_backup(hass) + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.unique_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "onedrive.mock_drive_id": {"protected": False, "size": 34519040} + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.unique_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_onedrive_client.delete_drive_item.call_count == 2 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.update_drive_item.assert_called_once() + + +async def test_agents_upload_corrupt_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hash validation fails.""" + mock_large_file_upload_client.side_effect = HashMismatchError("test") + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + assert "Hash validation failed, backup file might be corrupt" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_backup_file: File, + mock_metadata_file: File, +) -> None: + """Test we get not found on an not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + mock_onedrive_client.list_drive_items.side_effect = [ + [mock_backup_file, mock_metadata_file], + [], + ] + + with patch("homeassistant.components.onedrive.backup.CACHE_TTL", -1): + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 404 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + OneDriveException(), + "Backup operation failed", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + mock_onedrive_client.delete_drive_item.side_effect = AsyncMock( + side_effect=side_effect + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.unique_id}": error} + } + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_onedrive_client.list_drive_items.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test backup not found.""" + + mock_onedrive_client.list_drive_items.return_value = [] + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_reauth_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we re-authenticate on 403.""" + + mock_onedrive_client.list_drive_items.side_effect = AuthenticationError( + 403, "Auth failed" + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Authentication error" + } + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + # make sure it's the last listener + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py new file mode 100644 index 00000000000..81cd44bd041 --- /dev/null +++ b/tests/components/onedrive/test_config_flow.py @@ -0,0 +1,454 @@ +"""Test the OneDrive config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, Folder, ItemUpdate +import pytest + +from homeassistant import config_entries +from homeassistant.components.onedrive.const import ( + CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "Files.ReadWrite.AppFolder+offline_access+openid" + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client_init: MagicMock, +) -> None: + """Check full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow_with_owner_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, + mock_approot: MagicMock, +) -> None: + """Ensure we get a default title if the drive's owner can't be read.""" + + mock_approot.created_by.user = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + mock_onedrive_client.reset_mock() + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_folder_already_in_use( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, + mock_instance_id: AsyncMock, + mock_folder: Folder, +) -> None: + """Ensure a folder that is already in use is not allowed.""" + + mock_folder.description = "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_FOLDER_NAME: "folder_already_in_use"} + + # clear error and try again + mock_onedrive_client.create_folder.return_value.description = mock_instance_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_during_folder_creation( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we can create the backup folder.""" + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "folder_creation_error"} + + mock_onedrive_client.create_folder.side_effect = None + + # clear error and try again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "unknown"), + (OneDriveException, "connection_error"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors during flow.""" + + mock_onedrive_client.get_approot.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test already configured account.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, +) -> None: + """Test that the reauth flow fails on a different drive id.""" + + mock_approot.parent_reference.drive_id = "other_drive_id" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow.""" + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.ABORT + mock_onedrive_client.update_drive_item.assert_called_once_with( + mock_config_entry.data[CONF_FOLDER_ID], ItemUpdate(name="newFolder") + ) + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow errors.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + + mock_onedrive_client.update_drive_item.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + assert result["errors"] == {"base": "folder_rename_error"} + + # clear side effect + mock_onedrive_client.update_drive_item.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, +) -> None: + """Test that the reconfigure flow fails on a different drive id.""" + + mock_approot.parent_reference.drive_id = "other_drive_id" + + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" + + +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_DELETE_PERMANENTLY: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_DELETE_PERMANENTLY: True, + } diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py new file mode 100644 index 00000000000..f82d9925ee6 --- /dev/null +++ b/tests/components/onedrive/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the OneDrive integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py new file mode 100644 index 00000000000..952ca01e1cb --- /dev/null +++ b/tests/components/onedrive/test_init.py @@ -0,0 +1,300 @@ +"""Test the OneDrive setup.""" + +from copy import copy +from html import escape +from json import dumps +from unittest.mock import MagicMock + +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + NotFoundError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from . import setup_integration +from .const import BACKUP_METADATA, INSTANCE_ID + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client_init: MagicMock, + mock_onedrive_client: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + + # make sure metadata migration is not called + assert mock_onedrive_client.upload_file.call_count == 0 + assert mock_onedrive_client.update_drive_item.call_count == 0 + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "state"), + [ + (AuthenticationError(403, "Auth failed"), ConfigEntryState.SETUP_ERROR), + (OneDriveException(), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_approot_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + side_effect: Exception, + state: ConfigEntryState, +) -> None: + """Test errors during approot retrieval.""" + mock_onedrive_client.get_approot.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_get_integration_folder_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty integration folder retrieval.""" + mock_onedrive_client.get_drive_item.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_get_integration_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, + mock_folder: Folder, +) -> None: + """Test faulty integration folder creation.""" + folder_name = copy(mock_config_entry.data[CONF_FOLDER_NAME]) + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_onedrive_client.create_folder.assert_called_once_with( + parent_id=mock_approot.id, + name=folder_name, + ) + # ensure the folder id and name are updated + assert mock_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert mock_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_get_integration_folder_creation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty integration folder creation error.""" + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + mock_onedrive_client.create_folder.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_update_instance_id_description( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_folder: Folder, +) -> None: + """Test we write the instance id to the folder.""" + mock_folder.description = "" + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.update_drive_item.assert_called_with( + mock_folder.id, ItemUpdate(description=INSTANCE_ID) + ) + + +async def test_migrate_metadata_files( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_backup_file: File, +) -> None: + """Test migration of metadata files.""" + mock_backup_file.description = escape( + dumps({**BACKUP_METADATA, "metadata_version": 1}) + ) + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.upload_file.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 2 + assert mock_onedrive_client.update_drive_item.call_args[1]["data"].description == "" + + +async def test_migrate_metadata_files_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test migration of metadata files errors.""" + mock_onedrive_client.list_drive_items.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_error_during_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test auth error during update.""" + mock_onedrive_client.get_drive.side_effect = AuthenticationError(403, "Auth failed") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mock_drive: Drive, +) -> None: + """Test the device.""" + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(DOMAIN, mock_drive.id)}) + assert device + assert device == snapshot + + +@pytest.mark.parametrize( + ( + "drive_state", + "issue_key", + "issue_exists", + ), + [ + (DriveState.NORMAL, "drive_full", False), + (DriveState.NORMAL, "drive_almost_full", False), + (DriveState.CRITICAL, "drive_almost_full", True), + (DriveState.CRITICAL, "drive_full", False), + (DriveState.EXCEEDED, "drive_almost_full", False), + (DriveState.EXCEEDED, "drive_full", True), + ], +) +async def test_data_cap_issues( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_drive: Drive, + drive_state: DriveState, + issue_key: str, + issue_exists: bool, +) -> None: + """Make sure we get issues for high data usage.""" + assert mock_drive.quota + mock_drive.quota.state = drive_state + + await setup_integration(hass, mock_config_entry) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, issue_key) + assert (issue is not None) == issue_exists + + +async def test_1_1_to_1_2_migration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_folder: Folder, +) -> None: + """Test migration from 1.1 to 1.2.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.minor_version == 2 + + +async def test_1_1_to_1_2_migration_failure( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 1.1 to 1.2 failure.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + + # will always 404 after migration, because of dummy id + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + + await setup_integration(hass, old_config_entry) + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert old_config_entry.minor_version == 1 + + +async def test_migration_guard_against_major_downgrade( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration guards against major downgrades.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + version=2, + ) + + await setup_integration(hass, old_config_entry) + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/onedrive/test_sensor.py b/tests/components/onedrive/test_sensor.py new file mode 100644 index 00000000000..ea9d93a9a7b --- /dev/null +++ b/tests/components/onedrive/test_sensor.py @@ -0,0 +1,64 @@ +"""Tests for OneDrive sensors.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from onedrive_personal_sdk.const import DriveType +from onedrive_personal_sdk.exceptions import HttpRequestException +from onedrive_personal_sdk.models.items import Drive +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the OneDrive sensors.""" + + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("attr", "side_effect"), + [ + ("side_effect", HttpRequestException(503, "Service Unavailable")), + ("return_value", Drive(id="id", name="name", drive_type=DriveType.PERSONAL)), + ], +) +async def test_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + freezer: FrozenDateTimeFactory, + attr: str, + side_effect: Any, +) -> None: + """Ensure sensors are going unavailable on update failure.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == "0.75" + + setattr(mock_onedrive_client.get_drive, attr, side_effect) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index ed15cac94be..595b660b722 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -7,57 +7,60 @@ from unittest.mock import MagicMock from pyownet.protocol import ProtocolError -from homeassistant.const import Platform - from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES -def setup_owproxy_mock_devices( - owproxy: MagicMock, platform: Platform, device_ids: list[str] -) -> None: +def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" - main_dir_return_value = [] - sub_dir_side_effect = [] - main_read_side_effect = [] - sub_read_side_effect = [] + dir_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = { + "/system/configuration/version": [b"3.2"], + } + + # Setup directory listing + dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] for device_id in device_ids: - _setup_owproxy_mock_device( - main_dir_return_value, - sub_dir_side_effect, - main_read_side_effect, - sub_read_side_effect, - device_id, - platform, - ) + _setup_owproxy_mock_device(dir_side_effect, read_side_effect, device_id) - # Ensure enough read side effect - dir_side_effect = [main_dir_return_value, *sub_dir_side_effect] - read_side_effect = ( - main_read_side_effect - + sub_read_side_effect - + [ProtocolError("Missing injected value")] * 20 - ) - owproxy.return_value.dir.side_effect = dir_side_effect - owproxy.return_value.read.side_effect = read_side_effect + def _dir(path: str) -> Any: + if (side_effect := dir_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _dir call: {path}") + result = side_effect.pop(0) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) + ): + raise result + return result + + def _read(path: str) -> Any: + if (side_effect := read_side_effect.get(path)) is None: + raise NotImplementedError(f"Unexpected _read call: {path}") + if len(side_effect) == 0: + raise ProtocolError(f"Missing injected value for: {path}") + result = side_effect.pop(0) + if isinstance(result, Exception) or ( + isinstance(result, type) and issubclass(result, Exception) + ): + raise result + return result + + owproxy.return_value.dir.side_effect = _dir + owproxy.return_value.read.side_effect = _read def _setup_owproxy_mock_device( - main_dir_return_value: list, - sub_dir_side_effect: list, - main_read_side_effect: list, - sub_read_side_effect: list, - device_id: str, - platform: Platform, + dir_side_effect: dict[str, list], read_side_effect: dict[str, list], device_id: str ) -> None: """Set up mock for owproxy.""" mock_device = MOCK_OWPROXY_DEVICES[device_id] - # Setup directory listing - main_dir_return_value += [f"/{device_id}/"] if "branches" in mock_device: # Setup branch directory listing for branch, branch_details in mock_device["branches"].items(): + sub_dir_side_effect = dir_side_effect.setdefault( + f"/{device_id}/{branch}", [] + ) sub_dir_side_effect.append( [ # dir on branch f"/{device_id}/{branch}/{sub_device_id}/" @@ -65,46 +68,31 @@ def _setup_owproxy_mock_device( ] ) - _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, - mock_device, - device_id, - platform, - ) + _setup_owproxy_mock_device_reads(read_side_effect, mock_device, "/", device_id) if "branches" in mock_device: - for branch_details in mock_device["branches"].values(): + for branch, branch_details in mock_device["branches"].items(): for sub_device_id, sub_device in branch_details.items(): _setup_owproxy_mock_device_reads( - main_read_side_effect, - sub_read_side_effect, + read_side_effect, sub_device, + f"/{device_id}/{branch}/", sub_device_id, - platform, ) def _setup_owproxy_mock_device_reads( - main_read_side_effect: list, - sub_read_side_effect: list, - mock_device: Any, - device_id: str, - platform: Platform, + read_side_effect: dict[str, list], mock_device: Any, root_path: str, device_id: str ) -> None: """Set up mock for owproxy.""" # Setup device reads - main_read_side_effect += [device_id[0:2].encode()] - if ATTR_INJECT_READS in mock_device: - main_read_side_effect += mock_device[ATTR_INJECT_READS] - - # Setup sub-device reads - device_sensors = mock_device.get(platform, []) - if platform is Platform.SENSOR and device_id.startswith("12"): - # We need to check if there is TAI8570 plugged in - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors - ) - sub_read_side_effect.extend( - expected_sensor[ATTR_INJECT_READS] for expected_sensor in device_sensors + family_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}/family", [] ) + family_read_side_effect += [device_id[0:2].encode()] + if ATTR_INJECT_READS in mock_device: + for k, v in mock_device[ATTR_INJECT_READS].items(): + device_read_side_effect = read_side_effect.setdefault( + f"{root_path}{device_id}{k}", [] + ) + device_read_side_effect += v diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 65a86b58f2f..9d4303eaa1c 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -7,7 +7,7 @@ from pyownet.protocol import ConnError import pytest from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -32,7 +32,7 @@ def get_device_id(request: pytest.FixtureRequest) -> str: @pytest.fixture(name="config_entry") -def get_config_entry(hass: HomeAssistant) -> ConfigEntry: +def get_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -54,14 +54,14 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="owproxy") -def get_owproxy() -> MagicMock: +def get_owproxy() -> Generator[MagicMock]: """Mock owproxy.""" with patch("homeassistant.components.onewire.onewirehub.protocol.proxy") as owproxy: yield owproxy @pytest.fixture(name="owproxy_with_connerror") -def get_owproxy_with_connerror() -> MagicMock: +def get_owproxy_with_connerror() -> Generator[MagicMock]: """Mock owproxy.""" with patch( "homeassistant.components.onewire.onewirehub.protocol.proxy", diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index a1bab9807d5..370bcc871c6 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -2,333 +2,256 @@ from pyownet.protocol import ProtocolError -from homeassistant.components.onewire.const import Platform - -ATTR_DEVICE_FILE = "device_file" ATTR_INJECT_READS = "inject_reads" MOCK_OWPROXY_DEVICES = { "00.111111111111": { - ATTR_INJECT_READS: [ - b"", # read device type - ], + ATTR_INJECT_READS: { + "/type": [b""], + }, }, "05.111111111111": { - ATTR_INJECT_READS: [ - b"DS2405", # read device type - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2405"], + "/PIO": [b" 1"], + }, }, "10.111111111111": { - ATTR_INJECT_READS: [ - b"DS18S20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18S20"], + "/temperature": [b" 25.123"], + }, }, "12.111111111111": { - ATTR_INJECT_READS: [ - b"DS2406", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 1025.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2406"], + # TAI8570 values are read twice: + # - once during init to make sure TAI8570 is accessible + # - once during first update to get the actual values + "/TAI8570/temperature": [b" 25.123", b" 25.123"], + "/TAI8570/pressure": [b" 1025.123", b" 1025.123"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/latch.A": [b" 1"], + "/latch.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 251123"}, - {ATTR_INJECT_READS: b" 248125"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + } }, "16.111111111111": { # Test case for issue #115984, where the device type cannot be read - ATTR_INJECT_READS: [ - ProtocolError(), # read device type - ], + ATTR_INJECT_READS: {"/type": [ProtocolError()]}, }, "1F.111111111111": { - ATTR_INJECT_READS: [ - b"DS2409", # read device type - ], + ATTR_INJECT_READS: {"/type": [b"DS2409"]}, "branches": { "aux": {}, "main": { "1D.111111111111": { - ATTR_INJECT_READS: [ - b"DS2423", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", - ATTR_INJECT_READS: b" 251123", - }, - { - ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.B", - ATTR_INJECT_READS: b" 248125", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2423"], + "/counter.A": [b" 251123"], + "/counter.B": [b" 248125"], + }, }, }, }, }, + "20.111111111111": { + ATTR_INJECT_READS: { + "/type": [b"DS2450"], + "/volt.A": [b" 1.1"], + "/volt.B": [b" 2.2"], + "/volt.C": [b" 3.3"], + "/volt.D": [b" 4.4"], + "/latestvolt.A": [b" 1.11"], + "/latestvolt.B": [b" 2.22"], + "/latestvolt.C": [b" 3.33"], + "/latestvolt.D": [b" 4.44"], + } + }, "22.111111111111": { - ATTR_INJECT_READS: [ - b"DS1822", # read device type - ], - Platform.SENSOR: [ - { - ATTR_INJECT_READS: ProtocolError, - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1822"], + "/temperature": [ProtocolError], + }, }, "26.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "28.111111111111": { - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + "/tempres": [b" 12"], + }, }, "28.222222222222": { # This device has precision options in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222222/temperature9", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature9": [b" 26.984"], + }, }, "28.222222222223": { # This device has an illegal precision option in the config entry - ATTR_INJECT_READS: [ - b"DS18B20", # read device type - ], - Platform.SENSOR: [ - { - ATTR_DEVICE_FILE: "/28.222222222223/temperature", - ATTR_INJECT_READS: b" 26.984", - }, - ], + ATTR_INJECT_READS: { + "/type": [b"DS18B20"], + "/temperature": [b" 26.984"], + }, }, "29.111111111111": { - ATTR_INJECT_READS: [ - b"DS2408", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2408"], + "/PIO.0": [b" 1"], + "/PIO.1": [b" 0"], + "/PIO.2": [b" 1"], + "/PIO.3": [ProtocolError], + "/PIO.4": [b" 1"], + "/PIO.5": [b" 0"], + "/PIO.6": [b" 1"], + "/PIO.7": [b" 0"], + "/latch.0": [b" 1"], + "/latch.1": [b" 0"], + "/latch.2": [b" 1"], + "/latch.3": [b" 0"], + "/latch.4": [b" 1"], + "/latch.5": [b" 0"], + "/latch.6": [b" 1"], + "/latch.7": [b" 0"], + "/sensed.0": [b" 1"], + "/sensed.1": [b" 0"], + "/sensed.2": [b" 0"], + "/sensed.3": [ProtocolError], + "/sensed.4": [b" 0"], + "/sensed.5": [b" 0"], + "/sensed.6": [b" 0"], + "/sensed.7": [b" 0"], + }, }, "30.111111111111": { - ATTR_INJECT_READS: [ - b"DS2760", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 26.984"}, - { - ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", - ATTR_INJECT_READS: b" 173.7563", - }, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2760"], + "/temperature": [b" 26.984"], + "/typeK/temperature": [b" 173.7563"], + "/volt": [b" 2.97"], + "/vis": [b" 0.12"], + }, }, "3A.111111111111": { - ATTR_INJECT_READS: [ - b"DS2413", # read device type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - {ATTR_INJECT_READS: b" 0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2413"], + "/PIO.A": [b" 1"], + "/PIO.B": [b" 0"], + "/sensed.A": [b" 1"], + "/sensed.B": [b" 0"], + }, }, "3B.111111111111": { - ATTR_INJECT_READS: [ - b"DS1825", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 28.243"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS1825"], + "/temperature": [b" 28.243"], + }, }, "42.111111111111": { - ATTR_INJECT_READS: [ - b"DS28EA00", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 29.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS28EA00"], + "/temperature": [b" 29.123"], + }, }, "A6.111111111111": { - ATTR_INJECT_READS: [ - b"DS2438", # read device type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 25.123"}, - {ATTR_INJECT_READS: b" 72.7563"}, - {ATTR_INJECT_READS: b" 73.7563"}, - {ATTR_INJECT_READS: b" 74.7563"}, - {ATTR_INJECT_READS: b" 75.7563"}, - { - ATTR_INJECT_READS: ProtocolError, - }, - {ATTR_INJECT_READS: b" 969.265"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 2.97"}, - {ATTR_INJECT_READS: b" 4.74"}, - {ATTR_INJECT_READS: b" 0.12"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b" 1"}, - ], + ATTR_INJECT_READS: { + "/type": [b"DS2438"], + "/temperature": [b" 25.123"], + "/humidity": [b" 72.7563"], + "/HIH3600/humidity": [b" 73.7563"], + "/HIH4000/humidity": [b" 74.7563"], + "/HIH5030/humidity": [b" 75.7563"], + "/HTM1735/humidity": [ProtocolError], + "/B1-R1-A/pressure": [b" 969.265"], + "/S3-R1-A/illuminance": [b" 65.8839"], + "/VAD": [b" 2.97"], + "/VDD": [b" 4.74"], + "/vis": [b" 0.12"], + "/IAD": [b" 1"], + }, }, "EF.111111111111": { - ATTR_INJECT_READS: [ - b"HobbyBoards_EF", # read type - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 67.745"}, - {ATTR_INJECT_READS: b" 65.541"}, - {ATTR_INJECT_READS: b" 25.123"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HobbyBoards_EF"], + "/humidity/humidity_corrected": [b" 67.745"], + "/humidity/humidity_raw": [b" 65.541"], + "/humidity/temperature": [b" 25.123"], + }, }, "EF.111111111112": { - ATTR_INJECT_READS: [ - b"HB_MOISTURE_METER", # read type - b" 1", # read is_leaf_0 - b" 1", # read is_leaf_1 - b" 0", # read is_leaf_2 - b" 0", # read is_leaf_3 - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 41.745"}, - {ATTR_INJECT_READS: b" 42.541"}, - {ATTR_INJECT_READS: b" 43.123"}, - {ATTR_INJECT_READS: b" 44.123"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_MOISTURE_METER"], + "/moisture/is_leaf.0": [b" 1"], + "/moisture/is_leaf.1": [b" 1"], + "/moisture/is_leaf.2": [b" 0"], + "/moisture/is_leaf.3": [b" 0"], + "/moisture/sensor.0": [b" 41.745"], + "/moisture/sensor.1": [b" 42.541"], + "/moisture/sensor.2": [b" 43.123"], + "/moisture/sensor.3": [b" 44.123"], + "/moisture/is_moisture.0": [b" 1"], + "/moisture/is_moisture.1": [b" 1"], + "/moisture/is_moisture.2": [b" 0"], + "/moisture/is_moisture.3": [b" 0"], + }, }, "EF.111111111113": { - ATTR_INJECT_READS: [ - b"HB_HUB", # read type - ], - Platform.BINARY_SENSOR: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], - Platform.SWITCH: [ - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - {ATTR_INJECT_READS: b"1"}, - {ATTR_INJECT_READS: b"0"}, - ], + ATTR_INJECT_READS: { + "/type": [b"HB_HUB"], + "/hub/branch.0": [b" 1"], + "/hub/branch.1": [b" 0"], + "/hub/branch.2": [b" 1"], + "/hub/branch.3": [b" 0"], + "/hub/short.0": [b" 1"], + "/hub/short.1": [b" 0"], + "/hub/short.2": [b" 1"], + "/hub/short.3": [b" 0"], + }, }, "7E.111111111111": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0068", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - {ATTR_INJECT_READS: b" 65.8839"}, - {ATTR_INJECT_READS: b" 41.375"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0068"], + "/EDS0068/temperature": [b" 13.9375"], + "/EDS0068/pressure": [b" 1012.21"], + "/EDS0068/light": [b" 65.8839"], + "/EDS0068/humidity": [b" 41.375"], + }, }, "7E.222222222222": { - ATTR_INJECT_READS: [ - b"EDS", # read type - b"EDS0066", # read device_type - note EDS specific - ], - Platform.SENSOR: [ - {ATTR_INJECT_READS: b" 13.9375"}, - {ATTR_INJECT_READS: b" 1012.21"}, - ], + ATTR_INJECT_READS: { + "/type": [b"EDS"], + "/device_type": [b"EDS0066"], + "/EDS0066/temperature": [b" 13.9375"], + "/EDS0066/pressure": [b" 1012.21"], + }, }, } diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 450cc4c7486..10122ba8685 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -1,1645 +1,789 @@ # serializer version: 1 -# name: test_binary_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[10.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[10.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.A', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.A', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.A', + 'friendly_name': '12.111111111111 Sensed A', + 'raw_value': 1.0, }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/12.111111111111/sensed.B', - 'unit_of_measurement': None, + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.A', - 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/sensed.B', - 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.12_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/12.111111111111/sensed.B', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.12_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/sensed.B', + 'friendly_name': '12.111111111111 Sensed B', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.12_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[1D.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1D.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[1F.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[1F.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.0', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[22.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[22.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.0', + 'friendly_name': '29.111111111111 Sensed 0', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[26.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[26.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[28.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[28.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.1', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222222].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.1', + 'friendly_name': '29.111111111111 Sensed 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[28.222222222222].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.2', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.2', + 'friendly_name': '29.111111111111 Sensed 2', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[28.222222222223].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.3', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[28.222222222223].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.3', + 'friendly_name': '29.111111111111 Sensed 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- -# name: test_binary_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.4', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.0', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.4', + 'friendly_name': '29.111111111111 Sensed 4', + 'raw_value': 0.0, }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/29.111111111111/sensed.7', - 'unit_of_measurement': None, - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.0', - 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.1', - 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.2', - 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.3', - 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.5', + 'friendly_name': '29.111111111111 Sensed 5', + 'raw_value': 0.0, }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.4', - 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.5', - 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.6', - 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/sensed.7', - 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.29_111111111111_sensed_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.6', + 'friendly_name': '29.111111111111 Sensed 6', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[30.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[30.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.A', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sensed B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'sensed_id', - 'unique_id': '/3A.111111111111/sensed.B', - 'unit_of_measurement': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/29.111111111111/sensed.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.29_111111111111_sensed_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/sensed.7', + 'friendly_name': '29.111111111111 Sensed 7', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.29_111111111111_sensed_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.A', - 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/sensed.B', - 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.A', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[3B.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[3B.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.A', + 'friendly_name': '3A.111111111111 Sensed A', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[42.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[42.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_binary_sensors[7E.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) -# --- -# name: test_binary_sensors[7E.222222222222].1 - list([ - ]) -# --- -# name: test_binary_sensors[7E.222222222222].2 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensed B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensed_id', + 'unique_id': '/3A.111111111111/sensed.B', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[A6.111111111111].1 - list([ - ]) -# --- -# name: test_binary_sensors[A6.111111111111].2 - list([ - ]) -# --- -# name: test_binary_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.3a_111111111111_sensed_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/sensed.B', + 'friendly_name': '3A.111111111111 Sensed B', + 'raw_value': 0.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[EF.111111111111].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.0', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111111].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.0', + 'friendly_name': 'EF.111111111113 Hub short on branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.1', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111112].1 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.1', + 'friendly_name': 'EF.111111111113 Hub short on branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- -# name: test_binary_sensors[EF.111111111112].2 - list([ - ]) +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.2', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.2', + 'friendly_name': 'EF.111111111113 Hub short on branch 2', + 'raw_value': 1.0, }), - ]) + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) # --- -# name: test_binary_sensors[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.0', - 'unit_of_measurement': None, +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.1', - 'unit_of_measurement': None, + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.2', - 'unit_of_measurement': None, + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hub short on branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_short_id', - 'unique_id': '/EF.111111111113/hub/short.3', - 'unit_of_measurement': None, - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hub short on branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_short_id', + 'unique_id': '/EF.111111111113/hub/short.3', + 'unit_of_measurement': None, + }) # --- -# name: test_binary_sensors[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.0', - 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', +# name: test_binary_sensors[binary_sensor.ef_111111111113_hub_short_on_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'device_file': '/EF.111111111113/hub/short.3', + 'friendly_name': 'EF.111111111113 Hub short on branch 3', + 'raw_value': 0.0, }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.1', - 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.2', - 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'device_file': '/EF.111111111113/hub/short.3', - 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'context': , + 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index f51fca7e988..c60d0a9748b 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -12,7 +12,10 @@ ]), 'manufacturer': 'Hobby Boards', 'model': 'HB_HUB', + 'model_id': 'HB_HUB', 'name': 'EF.111111111113', + 'serial_number': '111111111113', + 'sw_version': '3.2', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr new file mode 100644 index 00000000000..9b2a0e00a62 --- /dev/null +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -0,0 +1,727 @@ +# serializer version: 1 +# name: test_registry[05.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '05.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2405', + 'model_id': 'DS2405', + 'name': '05.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[10.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '10.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18S20', + 'model_id': 'DS18S20', + 'name': '10.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[12.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '12.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2406', + 'model_id': 'DS2406', + 'name': '12.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[1D.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1D.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2423', + 'model_id': 'DS2423', + 'name': '1D.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': , + }) +# --- +# name: test_registry[1F.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '1F.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2409', + 'model_id': 'DS2409', + 'name': '1F.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[20.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '20.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2450', + 'model_id': 'DS2450', + 'name': '20.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[22.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '22.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1822', + 'model_id': 'DS1822', + 'name': '22.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[26.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '26.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': 'DS2438', + 'name': '26.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[28.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222222', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[28.222222222223-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '28.222222222223', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS18B20', + 'model_id': 'DS18B20', + 'name': '28.222222222223', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222223', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[29.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '29.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2408', + 'model_id': 'DS2408', + 'name': '29.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[30.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '30.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2760', + 'model_id': 'DS2760', + 'name': '30.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[3A.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3A.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2413', + 'model_id': 'DS2413', + 'name': '3A.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[3B.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '3B.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS1825', + 'model_id': 'DS1825', + 'name': '3B.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[42.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '42.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS28EA00', + 'model_id': 'DS28EA00', + 'name': '42.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0068', + 'model_id': 'EDS0068', + 'name': '7E.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[7E.222222222222-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '7E.222222222222', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Embedded Data Systems', + 'model': 'EDS0066', + 'model_id': 'EDS0066', + 'name': '7E.222222222222', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '222222222222', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[A6.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'A6.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2438', + 'model_id': 'DS2438', + 'name': 'A6.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HobbyBoards_EF', + 'model_id': 'HobbyBoards_EF', + 'name': 'EF.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111112-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111112', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_MOISTURE_METER', + 'model_id': 'HB_MOISTURE_METER', + 'name': 'EF.111111111112', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111112', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- +# name: test_registry[EF.111111111113-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + 'EF.111111111113', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'model_id': 'HB_HUB', + 'name': 'EF.111111111113', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111113', + 'suggested_area': None, + 'sw_version': '3.2', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr new file mode 100644 index 00000000000..a896d946841 --- /dev/null +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_selects[select.28_111111111111_temperature_resolution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '9', + '10', + '11', + '12', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature resolution', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tempres', + 'unique_id': '/28.111111111111/tempres', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.28_111111111111_temperature_resolution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/28.111111111111/tempres', + 'friendly_name': '28.111111111111 Temperature resolution', + 'options': list([ + '9', + '10', + '11', + '12', + ]), + 'raw_value': 12.0, + }), + 'context': , + 'entity_id': 'select.28_111111111111_temperature_resolution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 261b081060c..eca459b4c57 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -1,3477 +1,3129 @@ # serializer version: 1 -# name: test_sensors[00.111111111111] - list([ - ]) -# --- -# name: test_sensors[00.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[00.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.10_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_sensors[05.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[05.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.10_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/10.111111111111/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[10.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.10_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/10.111111111111/temperature', +# name: test_sensors[sensor.10_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/10.111111111111/temperature', + 'friendly_name': '10.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.10_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[10.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/10.111111111111/temperature', - 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.10_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.12_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.12_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/12.111111111111/TAI8570/pressure', +# name: test_sensors[sensor.12_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/12.111111111111/TAI8570/pressure', + 'friendly_name': '12.111111111111 Pressure', + 'raw_value': 1025.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.12_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1025.123', + }) # --- -# name: test_sensors[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/12.111111111111/TAI8570/temperature', - 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.12_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/12.111111111111/TAI8570/pressure', - 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.12_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1025.1', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.12_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/12.111111111111/TAI8570/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[16.111111111111] - list([ - ]) -# --- -# name: test_sensors[16.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[16.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_sensors[1D.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_sensors[1F.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Counter B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'counter_id', - 'unique_id': '/1D.111111111111/counter.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_sensors[1F.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.A', - 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '251123', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/1F.111111111111/main/1D.111111111111/counter.B', - 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.1d_111111111111_counter_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '248125', - }), - ]) -# --- -# name: test_sensors[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[22.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.22_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/22.111111111111/temperature', +# name: test_sensors[sensor.12_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/12.111111111111/TAI8570/temperature', + 'friendly_name': '12.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.12_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[22.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/22.111111111111/temperature', - 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.22_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_sensors[sensor.1d_111111111111_counter_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - ]) -# --- -# name: test_sensors[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.A', + 'unit_of_measurement': None, + }) # --- -# name: test_sensors[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/temperature', +# name: test_sensors[sensor.1d_111111111111_counter_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.A', + 'friendly_name': '1D.111111111111 Counter A', + 'raw_value': 251123.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '251123', + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Counter B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'counter_id', + 'unique_id': '/1D.111111111111/counter.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.1d_111111111111_counter_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/1D.111111111111/counter.B', + 'friendly_name': '1D.111111111111 Counter B', + 'raw_value': 248125.0, + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.1d_111111111111_counter_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '248125', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.A', + 'friendly_name': '20.111111111111 Latest voltage A', + 'raw_value': 1.11, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.B', + 'friendly_name': '20.111111111111 Latest voltage B', + 'raw_value': 2.22, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.22', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.C', + 'friendly_name': '20.111111111111 Latest voltage C', + 'raw_value': 3.33, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.33', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.D', + 'friendly_name': '20.111111111111 Latest voltage D', + 'raw_value': 4.44, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.44', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.A', + 'friendly_name': '20.111111111111 Voltage A', + 'raw_value': 1.1, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.B', + 'friendly_name': '20.111111111111 Voltage B', + 'raw_value': 2.2, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.C', + 'friendly_name': '20.111111111111 Voltage C', + 'raw_value': 3.3, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.D', + 'friendly_name': '20.111111111111 Voltage D', + 'raw_value': 4.4, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.22_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/22.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.22_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/22.111111111111/temperature', + 'friendly_name': '22.111111111111 Temperature', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/humidity', + 'context': , + 'entity_id': 'sensor.22_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/26.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH3600/humidity', + 'friendly_name': '26.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih3600_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/26.111111111111/HIH3600/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/26.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH4000/humidity', + 'friendly_name': '26.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih4000_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/26.111111111111/HIH4000/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/26.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HIH5030/humidity', + 'friendly_name': '26.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_hih5030_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/26.111111111111/HIH5030/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/26.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/HTM1735/humidity', + 'friendly_name': '26.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_htm1735_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/26.111111111111/HTM1735/humidity', + 'context': , + 'entity_id': 'sensor.26_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.26_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/26.111111111111/humidity', + 'friendly_name': '26.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/B1-R1-A/pressure', - 'unit_of_measurement': , + 'context': , + 'entity_id': 'sensor.26_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.26_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/26.111111111111/S3-R1-A/illuminance', + 'friendly_name': '26.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/26.111111111111/VAD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/26.111111111111/VDD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/26.111111111111/vis', - 'unit_of_measurement': , - }), - ]) + 'context': , + 'entity_id': 'sensor.26_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/26.111111111111/temperature', - 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.26_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/humidity', - 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.8', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH3600/humidity', - 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.8', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH4000/humidity', - 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.8', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HIH5030/humidity', - 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/26.111111111111/HTM1735/humidity', - 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/26.111111111111/B1-R1-A/pressure', - 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/26.111111111111/S3-R1-A/illuminance', - 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VAD', - 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/VDD', - 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/26.111111111111/vis', - 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.111111111111/temperature', - 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222222_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222222/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222222/temperature9', - 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[28.222222222223].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.28_222222222223_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/28.222222222223/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[28.222222222223].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/28.222222222223/temperature', - 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.28_222222222223_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - ]) -# --- -# name: test_sensors[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[29.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[29.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[30.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Thermocouple K temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermocouple_temperature_k', - 'unique_id': '/30.111111111111/typeX/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/30.111111111111/volt', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage gradient', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis_gradient', - 'unique_id': '/30.111111111111/vis', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[30.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/temperature', - 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/30.111111111111/typeK/temperature', - 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '173.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/volt', - 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/30.111111111111/vis', - 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) -# --- -# name: test_sensors[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3A.111111111111].1 - list([ - ]) -# --- -# name: test_sensors[3A.111111111111].2 - list([ - ]) -# --- -# name: test_sensors[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[3B.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.3b_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/3B.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[3B.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/3B.111111111111/temperature', - 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.3b_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '28.2', - }), - ]) -# --- -# name: test_sensors[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[42.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.42_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/42.111111111111/temperature', - 'unit_of_measurement': , - }), - ]) -# --- -# name: test_sensors[42.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/42.111111111111/temperature', - 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.42_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.1', - }), - ]) -# --- -# name: test_sensors[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/pressure', +# name: test_sensors[sensor.26_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/26.111111111111/B1-R1-A/pressure', + 'friendly_name': '26.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/light', + 'context': , + 'entity_id': 'sensor.26_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/26.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/26.111111111111/temperature', + 'friendly_name': '26.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/26.111111111111/VAD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VAD', + 'friendly_name': '26.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/26.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/VDD', + 'friendly_name': '26.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/26.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.26_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/26.111111111111/vis', + 'friendly_name': '26.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.26_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.111111111111/temperature', + 'friendly_name': '28.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222222_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222222/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222222/temperature9', + 'friendly_name': '28.222222222222 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.28_222222222223_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/28.222222222223/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.28_222222222223_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/28.222222222223/temperature', + 'friendly_name': '28.222222222223 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.28_222222222223_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/temperature', + 'friendly_name': '30.111111111111 Temperature', + 'raw_value': 26.984, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.984', + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermocouple K temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermocouple_temperature_k', + 'unique_id': '/30.111111111111/typeX/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_thermocouple_k_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/30.111111111111/typeK/temperature', + 'friendly_name': '30.111111111111 Thermocouple K temperature', + 'raw_value': 173.7563, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_thermocouple_k_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '173.7563', + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage gradient', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis_gradient', + 'unique_id': '/30.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_vis_voltage_gradient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/vis', + 'friendly_name': '30.111111111111 VIS voltage gradient', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_vis_voltage_gradient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.30_111111111111_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/30.111111111111/volt', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.30_111111111111_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/30.111111111111/volt', + 'friendly_name': '30.111111111111 Voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.30_111111111111_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.3b_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/3B.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.3b_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/3B.111111111111/temperature', + 'friendly_name': '3B.111111111111 Temperature', + 'raw_value': 28.243, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3b_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.243', + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.42_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/42.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.42_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/42.111111111111/temperature', + 'friendly_name': '42.111111111111 Temperature', + 'raw_value': 29.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.42_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.123', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/7E.111111111111/EDS0068/humidity', + 'friendly_name': '7E.111111111111 Humidity', + 'raw_value': 41.375, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.7e_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.375', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.7e_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/7E.111111111111/EDS0068/light', + 'friendly_name': '7E.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.111111111111/EDS0068/humidity', - 'unit_of_measurement': '%', - }), - ]) + 'context': , + 'entity_id': 'sensor.7e_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[7E.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.111111111111/EDS0068/temperature', - 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9', +# name: test_sensors[sensor.7e_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.111111111111/EDS0068/pressure', - 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.2', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/7E.111111111111/EDS0068/light', - 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/7E.111111111111/EDS0068/humidity', - 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.7e_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.4', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[7E.222222222222].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/temperature', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.7e_222222222222_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/7E.222222222222/EDS0066/pressure', +# name: test_sensors[sensor.7e_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.111111111111/EDS0068/pressure', + 'friendly_name': '7E.111111111111 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.7e_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) # --- -# name: test_sensors[7E.222222222222].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/7E.222222222222/EDS0066/temperature', - 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.9', +# name: test_sensors[sensor.7e_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/7E.222222222222/EDS0066/pressure', - 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.7e_222222222222_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1012.2', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - ]) + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.111111111111/EDS0068/temperature', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_sensors[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/temperature', +# name: test_sensors[sensor.7e_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.111111111111/EDS0068/temperature', + 'friendly_name': '7E.111111111111 Temperature', + 'raw_value': 13.9375, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/humidity', - 'unit_of_measurement': '%', + 'context': , + 'entity_id': 'sensor.7e_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH3600 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih3600', - 'unique_id': '/A6.111111111111/HIH3600/humidity', - 'unit_of_measurement': '%', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH4000 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih4000', - 'unique_id': '/A6.111111111111/HIH4000/humidity', - 'unit_of_measurement': '%', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HIH5030 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_hih5030', - 'unique_id': '/A6.111111111111/HIH5030/humidity', - 'unit_of_measurement': '%', + 'name': None, + 'options': dict({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'HTM1735 humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_htm1735', - 'unique_id': '/A6.111111111111/HTM1735/humidity', - 'unit_of_measurement': '%', - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_pressure', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Pressure', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/7E.222222222222/EDS0066/pressure', + 'friendly_name': '7E.222222222222 Pressure', + 'raw_value': 1012.21, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'context': , + 'entity_id': 'sensor.7e_222222222222_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.21', + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.7e_222222222222_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/7E.222222222222/EDS0066/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.7e_222222222222_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/7E.222222222222/EDS0066/temperature', + 'friendly_name': '7E.222222222222 Temperature', + 'raw_value': 13.9375, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.7e_222222222222_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.9375', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH3600 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih3600', + 'unique_id': '/A6.111111111111/HIH3600/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih3600_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH3600/humidity', + 'friendly_name': 'A6.111111111111 HIH3600 humidity', + 'raw_value': 73.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '73.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH4000 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih4000', + 'unique_id': '/A6.111111111111/HIH4000/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih4000_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH4000/humidity', + 'friendly_name': 'A6.111111111111 HIH4000 humidity', + 'raw_value': 74.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HIH5030 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_hih5030', + 'unique_id': '/A6.111111111111/HIH5030/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_hih5030_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HIH5030/humidity', + 'friendly_name': 'A6.111111111111 HIH5030 humidity', + 'raw_value': 75.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HTM1735 humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_htm1735', + 'unique_id': '/A6.111111111111/HTM1735/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_htm1735_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/HTM1735/humidity', + 'friendly_name': 'A6.111111111111 HTM1735 humidity', + 'raw_value': None, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/A6.111111111111/humidity', + 'friendly_name': 'A6.111111111111 Humidity', + 'raw_value': 72.7563, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.7563', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/S3-R1-A/illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'device_file': '/A6.111111111111/S3-R1-A/illuminance', + 'friendly_name': 'A6.111111111111 Illuminance', + 'raw_value': 65.8839, + 'state_class': , 'unit_of_measurement': 'lx', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VAD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vad', - 'unique_id': '/A6.111111111111/VAD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VDD voltage', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vdd', - 'unique_id': '/A6.111111111111/VDD', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'VIS voltage difference', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'voltage_vis', - 'unique_id': '/A6.111111111111/vis', - 'unit_of_measurement': , - }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.8839', + }) # --- -# name: test_sensors[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/A6.111111111111/temperature', - 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', +# name: test_sensors[sensor.a6_111111111111_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/humidity', - 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '72.8', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH3600/humidity', - 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih3600_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '73.8', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH4000/humidity', - 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih4000_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '74.8', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HIH5030/humidity', - 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_hih5030_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '75.8', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/A6.111111111111/HTM1735/humidity', - 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_htm1735_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/A6.111111111111/B1-R1-A/pressure', - 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_pressure', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '969.3', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'device_file': '/A6.111111111111/S3-R1-A/illuminance', - 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.9', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VAD', - 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vad_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/VDD', - 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vdd_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.7', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'device_file': '/A6.111111111111/vis', - 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/B1-R1-A/pressure', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.a6_111111111111_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/A6.111111111111/B1-R1-A/pressure', + 'friendly_name': 'A6.111111111111 Pressure', + 'raw_value': 969.265, + 'state_class': , + 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '969.265', + }) # --- -# name: test_sensors[EF.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/humidity_corrected', - 'unit_of_measurement': '%', +# name: test_sensors[sensor.a6_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_raw_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Raw humidity', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'humidity_raw', - 'unique_id': '/EF.111111111111/humidity/humidity_raw', - 'unit_of_measurement': '%', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111111_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '/EF.111111111111/humidity/temperature', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/A6.111111111111/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/A6.111111111111/temperature', + 'friendly_name': 'A6.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) # --- -# name: test_sensors[EF.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_corrected', - 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '67.7', +# name: test_sensors[sensor.a6_111111111111_vad_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111111/humidity/humidity_raw', - 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_raw_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65.5', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'device_file': '/EF.111111111111/humidity/temperature', - 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111111_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.1', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - ]) + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VAD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vad', + 'unique_id': '/A6.111111111111/VAD', + 'unit_of_measurement': , + }) # --- -# name: test_sensors[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.a6_111111111111_vad_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VAD', + 'friendly_name': 'A6.111111111111 VAD voltage', + 'raw_value': 2.97, + 'state_class': , + 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.a6_111111111111_vad_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.97', + }) # --- -# name: test_sensors[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.0', +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VDD voltage', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vdd', + 'unique_id': '/A6.111111111111/VDD', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vdd_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/VDD', + 'friendly_name': 'A6.111111111111 VDD voltage', + 'raw_value': 4.74, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_vdd_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VIS voltage difference', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_vis', + 'unique_id': '/A6.111111111111/vis', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.a6_111111111111_vis_voltage_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/A6.111111111111/vis', + 'friendly_name': 'A6.111111111111 VIS voltage difference', + 'raw_value': 0.12, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.a6_111111111111_vis_voltage_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/humidity_corrected', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_corrected', + 'friendly_name': 'EF.111111111111 Humidity', + 'raw_value': 67.745, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wetness 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wetness_id', - 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'context': , + 'entity_id': 'sensor.ef_111111111111_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.745', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Raw humidity', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_raw', + 'unique_id': '/EF.111111111111/humidity/humidity_raw', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_raw_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111111/humidity/humidity_raw', + 'friendly_name': 'EF.111111111111 Raw humidity', + 'raw_value': 65.541, + 'state_class': , 'unit_of_measurement': '%', }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'context': , + 'entity_id': 'sensor.ef_111111111111_raw_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65.541', + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111111_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '/EF.111111111111/humidity/temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111111_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'device_file': '/EF.111111111111/humidity/temperature', + 'friendly_name': 'EF.111111111111 Temperature', + 'raw_value': 25.123, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ef_111111111111_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.2', + 'friendly_name': 'EF.111111111112 Moisture 2', + 'raw_value': 43.123, + 'state_class': , 'unit_of_measurement': , }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Moisture 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_id', - 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.123', + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_id', + 'unique_id': '/EF.111111111112/moisture/sensor.3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ef_111111111112_moisture_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'device_file': '/EF.111111111112/moisture/sensor.3', + 'friendly_name': 'EF.111111111112 Moisture 3', + 'raw_value': 44.123, + 'state_class': , 'unit_of_measurement': , }), - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_moisture_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.123', + }) # --- -# name: test_sensors[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.0', - 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '41.7', +# name: test_sensors[sensor.ef_111111111112_wetness_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'device_file': '/EF.111111111112/moisture/sensor.1', - 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_wetness_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42.5', + 'area_id': None, + 'capabilities': dict({ + 'state_class': , }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.2', - 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '43.1', + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pressure', - 'device_file': '/EF.111111111112/moisture/sensor.3', - 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ef_111111111112_moisture_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '44.1', + 'name': None, + 'options': dict({ }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.0', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, +# name: test_sensors[sensor.ef_111111111112_wetness_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.0', + 'friendly_name': 'EF.111111111112 Wetness 0', + 'raw_value': 41.745, + 'state_class': , + 'unit_of_measurement': '%', }), - ]) + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.745', + }) # --- -# name: test_sensors[EF.111111111113].1 - list([ - ]) +# name: test_sensors[sensor.ef_111111111112_wetness_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wetness 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wetness_id', + 'unique_id': '/EF.111111111112/moisture/sensor.1', + 'unit_of_measurement': '%', + }) # --- -# name: test_sensors[EF.111111111113].2 - list([ - ]) +# name: test_sensors[sensor.ef_111111111112_wetness_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'device_file': '/EF.111111111112/moisture/sensor.1', + 'friendly_name': 'EF.111111111112 Wetness 1', + 'raw_value': 42.541, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ef_111111111112_wetness_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.541', + }) # --- diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 3bc7a2d3def..8be414c7c1e 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -1,2565 +1,1814 @@ # serializer version: 1 -# name: test_switches[00.111111111111] - list([ - ]) -# --- -# name: test_switches[00.111111111111].1 - list([ - ]) -# --- -# name: test_switches[00.111111111111].2 - list([ - ]) -# --- -# name: test_switches[05.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '05.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2405', - 'model_id': None, - 'name': '05.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.05_111111111111_programmed_input_output', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio', - 'unique_id': '/05.111111111111/PIO', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[05.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/05.111111111111/PIO', - 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.05_111111111111_programmed_input_output', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[10.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '10.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18S20', - 'model_id': None, - 'name': '10.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[10.111111111111].1 - list([ - ]) -# --- -# name: test_switches[10.111111111111].2 - list([ - ]) -# --- -# name: test_switches[12.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '12.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2406', - 'model_id': None, - 'name': '12.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/12.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.12_111111111111_latch_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/12.111111111111/latch.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[12.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.A', - 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/PIO.B', - 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.A', - 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/12.111111111111/latch.B', - 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.12_111111111111_latch_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[16.111111111111] - list([ - ]) -# --- -# name: test_switches[16.111111111111].1 - list([ - ]) -# --- -# name: test_switches[16.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1D.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[1D.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1D.111111111111].2 - list([ - ]) -# --- -# name: test_switches[1F.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1F.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2409', - 'model_id': None, - 'name': '1F.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '1D.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2423', - 'model_id': None, - 'name': '1D.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': , - }), - ]) -# --- -# name: test_switches[1F.111111111111].1 - list([ - ]) -# --- -# name: test_switches[1F.111111111111].2 - list([ - ]) -# --- -# name: test_switches[22.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '22.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1822', - 'model_id': None, - 'name': '22.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[22.111111111111].1 - list([ - ]) -# --- -# name: test_switches[22.111111111111].2 - list([ - ]) -# --- -# name: test_switches[26.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '26.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': '26.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/26.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[26.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/26.111111111111/IAD', - 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.26_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[28.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.111111111111].1 - list([ - ]) -# --- -# name: test_switches[28.111111111111].2 - list([ - ]) -# --- -# name: test_switches[28.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222222].1 - list([ - ]) -# --- -# name: test_switches[28.222222222222].2 - list([ - ]) -# --- -# name: test_switches[28.222222222223] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '28.222222222223', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS18B20', - 'model_id': None, - 'name': '28.222222222223', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[28.222222222223].1 - list([ - ]) -# --- -# name: test_switches[28.222222222223].2 - list([ - ]) -# --- -# name: test_switches[29.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '29.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2408', - 'model_id': None, - 'name': '29.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/29.111111111111/PIO.7', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 4', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.4', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 5', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.5', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 6', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.6', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.29_111111111111_latch_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Latch 7', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'latch_id', - 'unique_id': '/29.111111111111/latch.7', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[29.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.0', - 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.1', - 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.2', - 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.3', - 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.4', - 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.5', - 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.6', - 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/PIO.7', - 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_programmed_input_output_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.0', - 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.1', - 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.2', - 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.3', - 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.4', - 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.5', - 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.6', - 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/29.111111111111/latch.7', - 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.29_111111111111_latch_7', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[30.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '30.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2760', - 'model_id': None, - 'name': '30.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[30.111111111111].1 - list([ - ]) -# --- -# name: test_switches[30.111111111111].2 - list([ - ]) -# --- -# name: test_switches[3A.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3A.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2413', - 'model_id': None, - 'name': '3A.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output A', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.A', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Programmed input-output B', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'pio_id', - 'unique_id': '/3A.111111111111/PIO.B', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[3A.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.A', - 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/3A.111111111111/PIO.B', - 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[3B.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '3B.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS1825', - 'model_id': None, - 'name': '3B.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[3B.111111111111].1 - list([ - ]) -# --- -# name: test_switches[3B.111111111111].2 - list([ - ]) -# --- -# name: test_switches[42.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '42.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS28EA00', - 'model_id': None, - 'name': '42.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[42.111111111111].1 - list([ - ]) -# --- -# name: test_switches[42.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0068', - 'model_id': None, - 'name': '7E.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.111111111111].1 - list([ - ]) -# --- -# name: test_switches[7E.111111111111].2 - list([ - ]) -# --- -# name: test_switches[7E.222222222222] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - '7E.222222222222', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Embedded Data Systems', - 'model': 'EDS0066', - 'model_id': None, - 'name': '7E.222222222222', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[7E.222222222222].1 - list([ - ]) -# --- -# name: test_switches[7E.222222222222].2 - list([ - ]) -# --- -# name: test_switches[A6.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'A6.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Maxim Integrated', - 'model': 'DS2438', - 'model_id': None, - 'name': 'A6.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current A/D control', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'iad', - 'unique_id': '/A6.111111111111/IAD', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[A6.111111111111].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/A6.111111111111/IAD', - 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.a6_111111111111_current_a_d_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - ]) -# --- -# name: test_switches[EF.111111111111] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111111', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HobbyBoards_EF', - 'model_id': None, - 'name': 'EF.111111111111', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111111].1 - list([ - ]) -# --- -# name: test_switches[EF.111111111111].2 - list([ - ]) -# --- -# name: test_switches[EF.111111111112] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111112', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_MOISTURE_METER', - 'model_id': None, - 'name': 'EF.111111111112', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaf sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaf_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_leaf.3', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Moisture sensor 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'moisture_sensor_id', - 'unique_id': '/EF.111111111112/moisture/is_moisture.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111112].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.0', - 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.1', - 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.2', - 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_leaf.3', - 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.0', - 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.1', - 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.2', - 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111112/moisture/is_moisture.3', - 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) -# --- -# name: test_switches[EF.111111111113] - list([ - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'onewire', - 'EF.111111111113', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Hobby Boards', - 'model': 'HB_HUB', - 'model_id': None, - 'name': 'EF.111111111113', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].1 - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 0', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.0', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 1', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.1', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 2', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.2', - 'unit_of_measurement': None, - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hub branch 3', - 'platform': 'onewire', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'hub_branch_id', - 'unique_id': '/EF.111111111113/hub/branch.3', - 'unit_of_measurement': None, - }), - ]) -# --- -# name: test_switches[EF.111111111113].2 - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.0', - 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_0', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.1', - 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.2', - 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_file': '/EF.111111111113/hub/branch.3', - 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, - }), - 'context': , - 'entity_id': 'switch.ef_111111111113_hub_branch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) +# name: test_switches[switch.05_111111111111_programmed_input_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio', + 'unique_id': '/05.111111111111/PIO', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.05_111111111111_programmed_input_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/05.111111111111/PIO', + 'friendly_name': '05.111111111111 Programmed input-output', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.05_111111111111_programmed_input_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.A', + 'friendly_name': '12.111111111111 Latch A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_latch_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/12.111111111111/latch.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_latch_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/latch.B', + 'friendly_name': '12.111111111111 Latch B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_latch_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.A', + 'friendly_name': '12.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/12.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/12.111111111111/PIO.B', + 'friendly_name': '12.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.12_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/26.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.26_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/26.111111111111/IAD', + 'friendly_name': '26.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.26_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.0', + 'friendly_name': '29.111111111111 Latch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.1', + 'friendly_name': '29.111111111111 Latch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.2', + 'friendly_name': '29.111111111111 Latch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.3', + 'friendly_name': '29.111111111111 Latch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.4', + 'friendly_name': '29.111111111111 Latch 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.5', + 'friendly_name': '29.111111111111 Latch 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.6', + 'friendly_name': '29.111111111111 Latch 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_latch_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latch 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latch_id', + 'unique_id': '/29.111111111111/latch.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_latch_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/latch.7', + 'friendly_name': '29.111111111111 Latch 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_latch_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.0', + 'friendly_name': '29.111111111111 Programmed input-output 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.1', + 'friendly_name': '29.111111111111 Programmed input-output 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.2', + 'friendly_name': '29.111111111111 Programmed input-output 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.3', + 'friendly_name': '29.111111111111 Programmed input-output 3', + 'raw_value': None, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 4', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.4', + 'friendly_name': '29.111111111111 Programmed input-output 4', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 5', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.5', + 'friendly_name': '29.111111111111 Programmed input-output 5', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 6', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.6', + 'friendly_name': '29.111111111111 Programmed input-output 6', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output 7', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/29.111111111111/PIO.7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.29_111111111111_programmed_input_output_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/29.111111111111/PIO.7', + 'friendly_name': '29.111111111111 Programmed input-output 7', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.29_111111111111_programmed_input_output_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.A', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.A', + 'friendly_name': '3A.111111111111 Programmed input-output A', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmed input-output B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pio_id', + 'unique_id': '/3A.111111111111/PIO.B', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.3a_111111111111_programmed_input_output_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/3A.111111111111/PIO.B', + 'friendly_name': '3A.111111111111 Programmed input-output B', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current A/D control', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'iad', + 'unique_id': '/A6.111111111111/IAD', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.a6_111111111111_current_a_d_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/A6.111111111111/IAD', + 'friendly_name': 'A6.111111111111 Current A/D control', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.a6_111111111111_current_a_d_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.0', + 'friendly_name': 'EF.111111111112 Leaf sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.1', + 'friendly_name': 'EF.111111111112 Leaf sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.2', + 'friendly_name': 'EF.111111111112 Leaf sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaf sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaf_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_leaf.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_leaf_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_leaf.3', + 'friendly_name': 'EF.111111111112 Leaf sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.0', + 'friendly_name': 'EF.111111111112 Moisture sensor 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.1', + 'friendly_name': 'EF.111111111112 Moisture sensor 1', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.2', + 'friendly_name': 'EF.111111111112 Moisture sensor 2', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moisture sensor 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'moisture_sensor_id', + 'unique_id': '/EF.111111111112/moisture/is_moisture.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111112_moisture_sensor_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111112/moisture/is_moisture.3', + 'friendly_name': 'EF.111111111112 Moisture sensor 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 0', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.0', + 'friendly_name': 'EF.111111111113 Hub branch 0', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 1', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.1', + 'friendly_name': 'EF.111111111113 Hub branch 1', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 2', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.2', + 'friendly_name': 'EF.111111111113 Hub branch 2', + 'raw_value': 1.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub branch 3', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hub_branch_id', + 'unique_id': '/EF.111111111113/hub/branch.3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ef_111111111113_hub_branch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_file': '/EF.111111111113/hub/branch.3', + 'friendly_name': 'EF.111111111113 Hub branch 3', + 'raw_value': 0.0, + }), + 'context': , + 'entity_id': 'switch.ef_111111111113_hub_branch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 31895f705ff..dd2f3874e36 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -3,57 +3,65 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.BINARY_SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.BINARY_SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire binary sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) + """Test for 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["29.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire binary sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 8 ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.BINARY_SENSOR, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index cf61ab190db..65bdaafc131 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for 1-Wire config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from pyownet import protocol @@ -11,20 +12,39 @@ from homeassistant.components.onewire.const import ( INPUT_ENTRY_DEVICE_SELECTION, MANUFACTURER_MAXIM, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +_HASSIO_DISCOVERY = HassioServiceInfo( + config={"host": "1302b8e0-owserver", "port": 4304, "addon": "owserver (1-wire)"}, + name="owserver (1-wire)", + slug="1302b8e0_owserver", + uuid="e3fa56560d93458b96a594cbcea3017e", +) +_ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("5.6.7.8"), + ip_addresses=[ip_address("5.6.7.8")], + hostname="ubuntu.local.", + name="OWFS (1-wire) Server", + port=4304, + type="_owserver._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture async def filled_device_registry( - hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> dr.DeviceRegistry: """Fill device registry with mock devices.""" for key in ("28.111111111111", "28.222222222222", "28.222222222223"): @@ -38,13 +58,31 @@ async def filled_device_registry( return device_registry -async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] + + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} + + +async def test_user_flow_recovery(hass: HomeAssistant) -> None: + """Test user flow recovery after invalid server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) # Invalid server with patch( @@ -56,9 +94,9 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} # Valid server with patch( @@ -69,19 +107,14 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 1234}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "1.2.3.4" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - CONF_PORT: 1234, - } - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "1.2.3.4" + assert new_entry.data == {CONF_HOST: "1.2.3.4", CONF_PORT: 1234} async def test_user_duplicate( - hass: HomeAssistant, config_entry: ConfigEntry, mock_setup_entry: AsyncMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test user duplicate flow.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -176,9 +209,113 @@ async def test_reconfigure_duplicate( assert other_config_entry.data == {CONF_HOST: "2.3.4.5", CONF_PORT: 2345} +async def test_hassio_flow(hass: HomeAssistant) -> None: + """Test HassIO discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "owserver (1-wire)" + assert new_entry.data == {CONF_HOST: "1302b8e0-owserver", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_hassio_duplicate(hass: HomeAssistant) -> None: + """Test HassIO discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_HASSIO}, + data=_HASSIO_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow(hass: HomeAssistant) -> None: + """Test zeroconf discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert not result["errors"] + + # Cannot connect to server => retry + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + side_effect=protocol.ConnError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Connect OK + with patch( + "homeassistant.components.onewire.onewirehub.protocol.proxy", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + new_entry = result["result"] + assert new_entry.title == "OWFS (1-wire) Server" + assert new_entry.data == {CONF_HOST: "ubuntu.local.", CONF_PORT: 4304} + + +@pytest.mark.usefixtures("config_entry") +async def test_zeroconf_duplicate(hass: HomeAssistant) -> None: + """Test zeroconf discovery duplicate flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_clear( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test clearing the options.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -201,8 +338,8 @@ async def test_user_options_clear( @pytest.mark.usefixtures("filled_device_registry") -async def test_user_options_empty_selection( - hass: HomeAssistant, config_entry: ConfigEntry +async def test_user_options_empty_selection_recovery( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test leaving the selection of devices empty.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -215,7 +352,7 @@ async def test_user_options_empty_selection( "28.222222222223": False, } - # Verify that an empty selection does not modify the options + # Verify that an empty selection shows the form again result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={INPUT_ENTRY_DEVICE_SELECTION: []}, @@ -224,10 +361,29 @@ async def test_user_options_empty_selection( assert result["step_id"] == "device_selection" assert result["errors"] == {"base": "device_not_selected"} + # Verify that a single selected device to configure comes back as a form with the device to configure + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={INPUT_ENTRY_DEVICE_SELECTION: ["28.111111111111"]}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"]["sensor_id"] == "28.111111111111" + + # Verify that the setting for the device comes back as default when no input is given + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"]["device_options"]["28.111111111111"]["precision"] + == "temperature" + ) + @pytest.mark.usefixtures("filled_device_registry") async def test_user_options_set_single( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test configuring a single device.""" # Clear config options to certify functionality when starting from scratch @@ -265,7 +421,7 @@ async def test_user_options_set_single( async def test_user_options_set_multiple( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, filled_device_registry: dr.DeviceRegistry, ) -> None: """Test configuring multiple consecutive devices in a row.""" @@ -328,7 +484,7 @@ async def test_user_options_set_multiple( async def test_user_options_no_devices( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that options does not change when no devices are available.""" assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index ecdae859597..60b57bd14f7 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from . import setup_owproxy_mock_devices +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,35 +19,21 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield -DEVICE_DETAILS = { - "device_info": { - "identifiers": [["onewire", "EF.111111111113"]], - "manufacturer": "Hobby Boards", - "model": "HB_HUB", - "name": "EF.111111111113", - }, - "family": "EF", - "id": "EF.111111111113", - "path": "/EF.111111111113/", - "type": "HB_HUB", -} - - @pytest.mark.parametrize("device_id", ["EF.111111111113"], indirect=True) async def test_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, owproxy: MagicMock, device_id: str, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 82ff75628c2..0748481c40b 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -3,23 +3,30 @@ from copy import deepcopy from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pyownet import protocol import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.onewire.const import DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("owproxy_with_connerror") -async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_connect_failure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test connection failure raises ConfigEntryNotReady.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -29,7 +36,7 @@ async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) - async def test_listing_failure( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test listing failure raises ConfigEntryNotReady.""" owproxy.return_value.dir.side_effect = protocol.OwnetError() @@ -42,7 +49,7 @@ async def test_listing_failure( @pytest.mark.usefixtures("owproxy") -async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test being able to unload an entry.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -57,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async def test_update_options( - hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock ) -> None: """Test update options triggers reload.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -77,11 +84,56 @@ async def test_update_options( assert owproxy.call_count == 2 -@patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries + for device_entry in device_entries: + assert device_entry == snapshot(name=f"{device_entry.name}-entry") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_registry_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device are correctly registered.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, ["1F.111111111111"]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 2 + ) + + +@patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: @@ -93,12 +145,12 @@ async def test_registry_cleanup( dead_id = "28.111111111111" # Initialise with two components - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id, dead_id]) + setup_owproxy_mock_devices(owproxy, [live_id, dead_id]) await hass.config_entries.async_setup(entry_id) await hass.async_block_till_done() # Reload with a device no longer on bus - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id]) + setup_owproxy_mock_devices(owproxy, [live_id]) await hass.config_entries.async_reload(entry_id) await hass.async_block_till_done() assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 diff --git a/tests/components/onewire/test_select.py b/tests/components/onewire/test_select.py new file mode 100644 index 00000000000..6e1c3277c73 --- /dev/null +++ b/tests/components/onewire/test_select.py @@ -0,0 +1,96 @@ +"""Tests for 1-Wire selects.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) + await hass.config_entries.async_setup(config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_selects_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire select entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + + +@pytest.mark.parametrize("device_id", ["28.111111111111"]) +async def test_selection_option_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, +) -> None: + """Test for 1-Wire select option service.""" + setup_owproxy_mock_devices(owproxy, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + + entity_id = "select.28_111111111111_temperature_resolution" + assert hass.states.get(entity_id).state == "12" + + # Test SELECT_OPTION service + owproxy.return_value.read.side_effect = [b" 9"] + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "9"}, + blocking=True, + ) + assert hass.states.get(entity_id).state == "9" diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index ba0e21701f8..f1ef2dfa11b 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -5,68 +5,75 @@ from copy import deepcopy import logging from unittest.mock import MagicMock, _patch_dict, patch +from freezegun.api import FrozenDateTimeFactory from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SENSOR]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire sensors.""" - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + """Test for 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["12.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire sensor entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 2 ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot @pytest.mark.parametrize("device_id", ["12.111111111111"]) async def test_tai8570_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, entity_registry: er.EntityRegistry, @@ -78,11 +85,11 @@ async def test_tai8570_sensors( """ mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] - mock_device[ATTR_INJECT_READS].append(OwnetError) - mock_device[ATTR_INJECT_READS].append(OwnetError) + mock_device[ATTR_INJECT_READS]["/TAI8570/temperature"] = [OwnetError] + mock_device[ATTR_INJECT_READS]["/TAI8570/pressure"] = [OwnetError] with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): - setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) with caplog.at_level(logging.DEBUG): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index 936e83f66ec..ca13a69e2da 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -3,11 +3,12 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.onewire.onewirehub import _DEVICE_SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -16,66 +17,73 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_owproxy_mock_devices +from .const import MOCK_OWPROXY_DEVICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.onewire._PLATFORMS", [Platform.SWITCH]): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switches( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, - device_id: str, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test for 1-Wire switches.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + """Test for 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, MOCK_OWPROXY_DEVICES.keys()) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - # Ensure devices are correctly registered - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize("device_id", ["05.111111111111"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches_delayed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + owproxy: MagicMock, + device_id: str, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for delayed 1-Wire switch entities.""" + setup_owproxy_mock_devices(owproxy, []) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + setup_owproxy_mock_devices(owproxy, [device_id]) + freezer.tick(_DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 ) - assert device_entries == snapshot - - # Ensure entities are correctly registered - entity_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - assert entity_entries == snapshot - - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) - # Some entities are disabled, enable them and reload before checking states - for ent in entity_entries: - entity_registry.async_update_entity(ent.entity_id, disabled_by=None) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - # Ensure entity states are correct - states = [hass.states.get(ent.entity_id) for ent in entity_entries] - assert states == snapshot @pytest.mark.parametrize("device_id", ["05.111111111111"]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_toggle( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, owproxy: MagicMock, device_id: str, ) -> None: """Test for 1-Wire switch TOGGLE service.""" - setup_owproxy_mock_devices(owproxy, Platform.SWITCH, [device_id]) + setup_owproxy_mock_devices(owproxy, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 064075d109e..689711888d8 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -34,8 +34,9 @@ def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: data = {CONF_HOST: info.host} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( @@ -52,8 +53,9 @@ def create_empty_config_entry() -> MockConfigEntry: data = {CONF_HOST: ""} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index f619127d9b9..28186503ead 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -6,18 +6,24 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, + OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, + OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, ) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + SsdpServiceInfo, +) from . import ( create_config_entry_from_info, @@ -84,35 +90,6 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> assert host_result["errors"]["base"] == "cannot_connect" -async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", - ) - config_entry.add_to_hass(hass) - - discovery_info = ssdp.SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_manual_valid_host_unexpected_error( hass: HomeAssistant, empty_mock_discovery ) -> None: @@ -232,9 +209,9 @@ async def test_ssdp_discovery_success( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with valid host.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", @@ -251,7 +228,11 @@ async def test_ssdp_discovery_success( select_result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, ) assert select_result["type"] is FlowResultType.CREATE_ENTRY @@ -259,11 +240,40 @@ async def test_ssdp_discovery_success( assert select_result["result"].unique_id == "id1" +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with already configured device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="id1", + ) + config_entry.add_to_hass(hass) + + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -286,9 +296,9 @@ async def test_ssdp_discovery_host_none_info( hass: HomeAssistant, stub_mock_discovery ) -> None: """Test SSDP discovery with host info error.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -307,9 +317,9 @@ async def test_ssdp_discovery_no_location( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with no location.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location=None, - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -328,9 +338,9 @@ async def test_ssdp_discovery_no_host( hass: HomeAssistant, default_mock_discovery ) -> None: """Test SSDP discovery with no host.""" - discovery_info = ssdp.SsdpServiceInfo( + discovery_info = SsdpServiceInfo( ssdp_location="http://", - upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_st="mock_st", ) @@ -345,34 +355,6 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_empty_source_list( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configuration with no sources set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": []}, - ) - - assert configure_result["errors"] == {"input_sources": "empty_input_source_list"} - - async def test_configure_no_resolution( hass: HomeAssistant, default_mock_discovery ) -> None: @@ -400,33 +382,61 @@ async def test_configure_no_resolution( ) -async def test_configure_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configure with specified resolution.""" +async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: + """Test receiver configure.""" - init_result = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"}, ) - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "sample-host-name"}, ) - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["THX"], + }, ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} - assert configure_result["type"] is FlowResultType.CREATE_ENTRY - assert configure_result["options"]["volume_resolution"] == 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["options"] == { + OPTION_VOLUME_RESOLUTION: 200, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: {"12": "TV"}, + OPTION_LISTENING_MODES: {"04": "THX"}, + } async def test_configure_invalid_resolution_set( @@ -463,7 +473,7 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: await setup_integration(hass, config_entry, receiver_info) old_host = config_entry.data[CONF_HOST] - old_max_volume = config_entry.options[OPTION_MAX_VOLUME] + old_options = config_entry.options result = await config_entry.start_reconfigure_flow(hass) @@ -480,7 +490,7 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TUNER"]}, + user_input={OPTION_VOLUME_RESOLUTION: 200}, ) assert result3["type"] is FlowResultType.ABORT @@ -488,7 +498,10 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: assert config_entry.data[CONF_HOST] == old_host assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 - assert config_entry.options[OPTION_MAX_VOLUME] == old_max_volume + for option, option_value in old_options.items(): + if option == OPTION_VOLUME_RESOLUTION: + continue + assert config_entry.options[option] == option_value async def test_reconfigure_new_device(hass: HomeAssistant) -> None: @@ -594,21 +607,26 @@ async def test_import_success( await hass.async_block_till_done() assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"]["host"] == "host 1" - assert import_result["options"]["volume_resolution"] == 80 - assert import_result["options"]["max_volume"] == 100 - assert import_result["options"]["input_sources"] == { - "00": "Auxiliary", - "01": "Video", + assert import_result["data"] == {"host": "host 1"} + assert import_result["options"] == { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": { + "00": "Auxiliary", + "01": "Video", + }, + "listening_modes": {}, } @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ - [ # The schema is dynamically created from input sources - "component.onkyo.options.step.init.data.TV", - "component.onkyo.options.step.init.data_description.TV", + [ # The schema is dynamically created from input sources and listening modes + "component.onkyo.options.step.names.sections.input_sources.data.TV", + "component.onkyo.options.step.names.sections.input_sources.data_description.TV", + "component.onkyo.options.step.names.sections.listening_modes.data.STEREO", + "component.onkyo.options.step.names.sections.listening_modes.data_description.STEREO", ] ], ) @@ -619,23 +637,60 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) config_entry = create_empty_config_entry() await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] + result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "max_volume": 42, - "TV": "television", + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["STEREO"], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["STEREO"], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "names" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_INPUT_SOURCES: {"TV": "television"}, + OPTION_LISTENING_MODES: {"STEREO": "Duophonia"}, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "volume_resolution": 80, - "max_volume": 42.0, - "input_sources": { - "12": "television", - }, + OPTION_VOLUME_RESOLUTION: old_volume_resolution, + OPTION_MAX_VOLUME: 42.0, + OPTION_INPUT_SOURCES: {"12": "television"}, + OPTION_LISTENING_MODES: {"00": "Duophonia"}, } diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index c8a9ff75d62..c3938efcbb6 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 5c01fb2d200..0bad7050fd9 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -6,13 +6,13 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_DHCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( HOST, @@ -44,10 +44,10 @@ DISCOVERY = [ "MAC": "ff:ee:dd:cc:bb:aa", }, ] -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="any", ip="5.6.7.8", macaddress=MAC.lower().replace(":", "") ) -DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY_SAME_IP = DhcpServiceInfo( hostname="any", ip="1.2.3.4", macaddress=MAC.lower().replace(":", "") ) diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 209e7cbccef..4f7e10abae6 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -119,7 +119,83 @@ async def test_line_detector_crossed(hass: HomeAssistant) -> None: ) -async def test_tapo_vehicle(hass: HomeAssistant) -> None: +async def test_tapo_line_crossed(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/LineCross.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/LineCross", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyLineCrossDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsLineCross", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 21, 5, 14, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Line Detector Crossed" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "LineCross_VideoSourceToken_VideoAnalyticsToken_MyLineCrossDetectorRule" + ) + + +async def test_tapo_tpsmartevent_vehicle(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle.""" event = await get_event( { @@ -198,7 +274,83 @@ async def test_tapo_vehicle(hass: HomeAssistant) -> None: ) -async def test_tapo_person(hass: HomeAssistant) -> None: +async def test_tapo_cellmotiondetector_vehicle(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/TpSmartEvent - vehicle.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/TpSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsVehicle", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 14, 2, 9, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Vehicle Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "TpSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None: """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person.""" event = await get_event( { @@ -274,6 +426,310 @@ async def test_tapo_person(hass: HomeAssistant) -> None: ) +async def test_tapo_tpsmartevent_pet(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - pet.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPet", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 22, 13, 24, 57, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Pet Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/" + "TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule" + ) + + +async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/People - person.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.56.63:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/People", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.56.63:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyPeopleDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 3, 20, 9, 22, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Person Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule" + ) + + +async def test_tapo_tamper(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Tamper - tamper.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Tamper", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://CAMERA_LOCAL_IP:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyTamperDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsTamper", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 5, 21, 1, 5, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Tamper Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "tamper" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Tamper_VideoSourceToken_VideoAnalyticsToken_MyTamperDetectorRule" + ) + + +async def test_tapo_intrusion(hass: HomeAssistant) -> None: + """Tests tns1:RuleEngine/CellMotionDetector/Intrusion - intrusion.""" + event = await get_event( + { + "SubscriptionReference": { + "Address": { + "_value_1": "http://192.168.100.155:2020/event-0_2020", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Topic": { + "_value_1": "tns1:RuleEngine/CellMotionDetector/Intrusion", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": { + "Address": { + "_value_1": "http://192.168.100.155:5656/event", + "_attr_1": None, + }, + "ReferenceParameters": None, + "Metadata": None, + "_value_1": None, + "_attr_1": None, + }, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [ + { + "Name": "VideoSourceConfigurationToken", + "Value": "vsconf", + }, + { + "Name": "VideoAnalyticsConfigurationToken", + "Value": "VideoAnalyticsToken", + }, + {"Name": "Rule", "Value": "MyIntrusionDetectorRule"}, + ], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "IsIntrusion", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 1, 11, 10, 40, 45, tzinfo=datetime.UTC + ), + "PropertyOperation": "Changed", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Intrusion Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "safety" + assert event.value + assert event.uid == ( + f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/" + "Intrusion_VideoSourceToken_VideoAnalyticsToken_MyIntrusionDetectorRule" + ) + + async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: """Tests async_parse_tplink_detector with missing fields.""" event = await get_event( diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index eaa3a9de64c..77c28de2773 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,34 +1,50 @@ # serializer version: 1 -# name: test_unknown_hass_api - dict({ - 'conversation_id': None, - 'response': IntentResponse( - card=dict({ - }), - error_code=, - failed_results=list([ - ]), - intent=None, - intent_targets=list([ - ]), - language='en', - matched_states=list([ - ]), - reprompt=dict({ - }), - response_type=, - speech=dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Error preparing LLM API', +# name: test_function_call + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + dict({ + 'id': 'call_call_2', + 'tool_args': dict({ + 'param1': 'call2', + }), + 'tool_name': 'test_tool', }), - }), - speech_slots=dict({ - }), - success_results=list([ ]), - unmatched_states=list([ - ]), - ), - }) + }), + dict({ + 'agent_id': 'conversation.openai', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.openai', + 'role': 'tool_result', + 'tool_call_id': 'call_call_2', + 'tool_name': 'test_tool', + 'tool_result': 'value2', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': 'Cool', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) # --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index f5017c124b1..90a08471f39 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -12,12 +12,14 @@ from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TOP_P, ) from homeassistant.const import CONF_LLM_HASS_API @@ -88,6 +90,27 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL +async def test_options_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form giving error about models not supported.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + result = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_CHAT_MODEL: "o1-mini", + CONF_LLM_HASS_API: "assist", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"chat_model": "model_not_supported"} + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -148,6 +171,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, }, ), ( @@ -158,6 +182,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, }, { CONF_RECOMMENDED: True, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index eed396786e2..238fd5f2d7b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,30 +1,70 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch -from freezegun import freeze_time from httpx import Response -from openai import RateLimitError -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_message import ChatCompletionMessage -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, +from openai import AuthenticationError, RateLimitError +from openai.types.chat.chat_completion_chunk import ( + ChatCompletionChunk, + Choice, + ChoiceDelta, + ChoiceDeltaToolCall, + ChoiceDeltaToolCallFunction, ) -from openai.types.completion_usage import CompletionUsage +import pytest from syrupy.assertion import SnapshotAssertion -import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import trace +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -from homeassistant.util import ulid from tests.common import MockConfigEntry +from tests.components.conversation import ( + MockChatLog, + mock_chat_log, # noqa: F401 +) + +ASSIST_RESPONSE_FINISH = ( + # Assistant message + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + ), + # Finish stream + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[Choice(index=0, finish_reason="stop", delta=ChoiceDelta())], + ), +) + + +@pytest.fixture +def mock_create_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(stream): + for value in stream: + yield value + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + AsyncMock(), + ) as mock_create: + mock_create.side_effect = lambda **kwargs: mock_generator( + mock_create.return_value.pop(0) + ) + + yield mock_create async def test_entity( @@ -54,200 +94,42 @@ async def test_entity( ) -async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that the default prompt works.""" - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=RateLimitError( - response=Response(status_code=None, request=""), body=None, message=None - ), - ): - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_variables( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - context = Context(user_id="12345") - mock_user = Mock() - mock_user.id = "12345" - mock_user.name = "Test User" - - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": ( - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." +@pytest.mark.parametrize( + ("exception", "message"), + [ + ( + RateLimitError( + response=Response(status_code=429, request=""), body=None, message=None ), - }, - ) - with ( - patch( - "openai.resources.models.AsyncModels.list", + "Rate limited or insufficient funds", ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create, - patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, context, agent_id=mock_config_entry.entry_id - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert ( - "The user name is Test User." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - assert ( - "The user id is 12345." - in mock_create.mock_calls[0][2]["messages"][0]["content"] - ) - - -async def test_extra_systen_prompt( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + ( + AuthenticationError( + response=Response(status_code=401, request=""), body=None, message=None + ), + "Error talking to OpenAI", + ), + ], +) +async def test_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + exception, + message, ) -> None: - """Test that template variables work.""" - extra_system_prompt = "Garage door cover.garage_door has been left open for 30 minutes. We asked the user if they want to close it." - extra_system_prompt2 = ( - "User person.paulus came home. Asked him what he wants to do." - ) - - with ( - patch( - "openai.resources.models.AsyncModels.list", - ), - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create, + """Test that we handle errors when calling completion API.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=exception, ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id=mock_config_entry.entry_id, - extra_system_prompt=extra_system_prompt, + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt - ) - - conversation_id = result.conversation_id - - # Verify that follow-up conversations with no system prompt take previous one - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create: - result = await conversation.async_converse( - hass, - "hello", - conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - extra_system_prompt=None, - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt - ) - - # Verify that we take new system prompts - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create: - result = await conversation.async_converse( - hass, - "hello", - conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - extra_system_prompt=extra_system_prompt2, - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt2 - ) - - # Verify that follow-up conversations with no system prompt take previous one - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - ) as mock_create: - result = await conversation.async_converse( - hass, - "hello", - conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - ) - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert mock_create.mock_calls[0][2]["messages"][0]["content"].endswith( - extra_system_prompt2 - ) + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.speech["plain"]["speech"] == message, result.response.speech async def test_conversation_agent( @@ -262,408 +144,299 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -@patch( - "homeassistant.components.openai_conversation.conversation.llm.AssistAPI._async_get_tools" -) async def test_function_call( - mock_get_tools, hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + snapshot: SnapshotAssertion, ) -> None: """Test function call from the assistant.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} - ) - mock_tool.async_call.return_value = "Test response" - - mock_get_tools.return_value = [mock_tool] - - def completion_result(*args, messages, **kwargs): - for message in messages: - role = message["role"] if isinstance(message, dict) else message.role - if role == "tool": - return ChatCompletion( - id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", - choices=[ - Choice( - finish_reason="stop", - index=0, - message=ChatCompletionMessage( - content="I have successfully called the function", - role="assistant", - function_call=None, - tool_calls=None, - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ) - - return ChatCompletion( - id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", - choices=[ - Choice( - finish_reason="tool_calls", - index=0, - message=ChatCompletionMessage( - content=None, - role="assistant", - function_call=None, - tool_calls=[ - ChatCompletionMessageToolCall( - id="call_AbCdEfGhIjKlMnOpQrStUvWx", - function=Function( - arguments='{"param1":"test_value"}', - name="test_tool", - ), - type="function", - ) - ], - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 + mock_create_stream.return_value = [ + # Initial conversation + ( + # First tool call + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_1", + index=0, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments=None, + ), + ) + ] + ), + ) + ], ), - ) + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction( + name=None, + arguments='{"para', + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction( + name=None, + arguments='m1":"call1"}', + ), + ) + ] + ), + ) + ], + ), + # Second tool call + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_2", + index=1, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments='{"param1":"call2"}', + ), + ) + ] + ), + ) + ], + ), + # Finish stream + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice(index=0, finish_reason="tool_calls", delta=ChoiceDelta()) + ], + ), + ), + # Response after tool responses + ASSIST_RESPONSE_FINISH, + ] + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) - with ( - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create, - freeze_time("2024-06-03 23:00:00"), - ): - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - ) - - assert ( - "Today's date is 2024-06-03." - in mock_create.mock_calls[1][2]["messages"][0]["content"] + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_create.mock_calls[1][2]["messages"][3] == { - "role": "tool", - "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", - "content": '"Test response"', - } - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - tool_name="test_tool", - tool_args={"param1": "test_value"}, + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize( + ("description", "messages"), + [ + ( + "Test function call started with missing arguments", + ( + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_1", + index=0, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments=None, + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + ), + ), ), - llm.LLMContext( - platform="openai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id=None, + ( + "Test invalid JSON", + ( + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_1", + index=0, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments=None, + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction( + name=None, + arguments='{"para', + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(content="Cool"), + finish_reason="tool_calls", + ) + ], + ), + ), ), - ) - - # Test Conversation tracing - traces = trace.async_get_traces() - assert traces - last_trace = traces[-1].as_dict() - trace_events = last_trace.get("events", []) - assert [event["event_type"] for event in trace_events] == [ - trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, - trace.ConversationTraceEventType.TOOL_CALL, - ] - # AGENT_DETAIL event contains the raw prompt passed to the model - detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] - assert ( - "Today's date is 2024-06-03." - in trace_events[1]["data"]["messages"][0]["content"] - ) - assert [t.name for t in detail_event["data"]["tools"]] == ["test_tool"] - - # Call it again, make sure we have updated prompt - with ( - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create, - freeze_time("2024-06-04 23:00:00"), - ): - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - ) - - assert ( - "Today's date is 2024-06-04." - in mock_create.mock_calls[1][2]["messages"][0]["content"] - ) - # Test old assert message not updated - assert ( - "Today's date is 2024-06-03." - in trace_events[1]["data"]["messages"][0]["content"] - ) - - -@patch( - "homeassistant.components.openai_conversation.conversation.llm.AssistAPI._async_get_tools" + ], ) -async def test_function_exception( - mock_get_tools, +async def test_function_call_invalid( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + description: str, + messages: tuple[ChatCompletionChunk], ) -> None: - """Test function call with exception.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() + """Test function call containing invalid data.""" + mock_create_stream.return_value = [messages] - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} - ) - mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - - mock_get_tools.return_value = [mock_tool] - - def completion_result(*args, messages, **kwargs): - for message in messages: - role = message["role"] if isinstance(message, dict) else message.role - if role == "tool": - return ChatCompletion( - id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", - choices=[ - Choice( - finish_reason="stop", - index=0, - message=ChatCompletionMessage( - content="There was an error calling the function", - role="assistant", - function_call=None, - tool_calls=None, - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ) - - return ChatCompletion( - id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", - choices=[ - Choice( - finish_reason="tool_calls", - index=0, - message=ChatCompletionMessage( - content=None, - role="assistant", - function_call=None, - tool_calls=[ - ChatCompletionMessageToolCall( - id="call_AbCdEfGhIjKlMnOpQrStUvWx", - function=Function( - arguments='{"param1":"test_value"}', - name="test_tool", - ), - type="function", - ) - ], - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ) - - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create: - result = await conversation.async_converse( + with pytest.raises(ValueError): + await conversation.async_converse( hass, "Please call the test function", - None, - context, - agent_id=agent_id, + "mock-conversation-id", + Context(), + agent_id="conversation.openai", ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_create.mock_calls[1][2]["messages"][3] == { - "role": "tool", - "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", - "content": '{"error": "HomeAssistantError", "error_text": "Test tool exception"}', - } - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - tool_name="test_tool", - tool_args={"param1": "test_value"}, - ), - llm.LLMContext( - platform="openai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id=None, - ), - ) - async def test_assist_api_tools_conversion( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + mock_create_stream, ) -> None: """Test that we are able to convert actual tools from Assist API.""" for component in ( - "intent", - "todo", - "light", - "shopping_list", - "humidifier", + "calendar", "climate", - "media_player", - "vacuum", "cover", + "humidifier", + "intent", + "light", + "media_player", + "script", + "shopping_list", + "todo", + "vacuum", "weather", ): assert await async_setup_component(hass, component, {}) + hass.states.async_set(f"{component}.test", "on") + async_expose_entity(hass, "conversation", f"{component}.test", True) - agent_id = mock_config_entry_with_assist.entry_id - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - return_value=ChatCompletion( - id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", - choices=[ - Choice( - finish_reason="stop", - index=0, - message=ChatCompletionMessage( - content="Hello, how can I help you?", - role="assistant", - function_call=None, - tool_calls=None, - ), - ) - ], - created=1700000000, - model="gpt-3.5-turbo-0613", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ), - ) as mock_create: - await conversation.async_converse( - hass, "hello", None, Context(), agent_id=agent_id - ) + mock_create_stream.return_value = [ASSIST_RESPONSE_FINISH] - tools = mock_create.mock_calls[0][2]["tools"] + await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.openai" + ) + + tools = mock_create_stream.mock_calls[0][2]["tools"] assert tools - - -async def test_unknown_hass_api( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - mock_init_component, -) -> None: - """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - **mock_config_entry.options, - CONF_LLM_HASS_API: "non-existing", - }, - ) - - await hass.async_block_till_done() - - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result == snapshot - - -@patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, -) -async def test_conversation_id( - mock_create, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, -) -> None: - """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id=mock_config_entry.entry_id - ) - - conversation_id = result.conversation_id - - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == conversation_id - - unknown_id = ulid.ulid() - - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id != unknown_id - - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id=mock_config_entry.entry_id - ) - - assert result.conversation_id == "koala" diff --git a/tests/components/openhome/test_config_flow.py b/tests/components/openhome/test_config_flow.py index 7ab1e69106c..6430b8610e9 100644 --- a/tests/components/openhome/test_config_flow.py +++ b/tests/components/openhome/test_config_flow.py @@ -1,12 +1,15 @@ """Tests for the Openhome config flow module.""" -from homeassistant.components import ssdp from homeassistant.components.openhome.const import DOMAIN -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -14,7 +17,7 @@ MOCK_UDN = "uuid:4c494e4e-1234-ab12-abcd-01234567819f" MOCK_FRIENDLY_NAME = "Test Client" MOCK_SSDP_LOCATION = "http://device:12345/description.xml" -MOCK_DISCOVER = ssdp.SsdpServiceInfo( +MOCK_DISCOVER = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -60,7 +63,7 @@ async def test_device_exists(hass: HomeAssistant) -> None: async def test_missing_udn(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location=MOCK_SSDP_LOCATION, @@ -79,7 +82,7 @@ async def test_missing_udn(hass: HomeAssistant) -> None: async def test_missing_ssdp_location(hass: HomeAssistant) -> None: """Test a ssdp import where discovery is missing udn.""" - broken_discovery = ssdp.SsdpServiceInfo( + broken_discovery = SsdpServiceInfo( ssdp_usn="usn", ssdp_st="st", ssdp_location="", diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 57bea4e55dc..99a2dde4acc 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -54,30 +54,6 @@ async def test_form_user( assert mock_pyotgw.return_value.disconnect.await_count == 1 -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def test_form_import( - hass: HomeAssistant, - mock_pyotgw: MagicMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test import from existing config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "legacy_gateway" - assert result["data"] == { - CONF_NAME: "legacy_gateway", - CONF_DEVICE: "/dev/ttyUSB1", - CONF_ID: "legacy_gateway", - } - assert mock_pyotgw.return_value.connect.await_count == 1 - assert mock_pyotgw.return_value.disconnect.await_count == 1 - - async def test_form_duplicate_entries( hass: HomeAssistant, mock_pyotgw: MagicMock, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 3e85afbf782..84629137ce1 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -4,18 +4,12 @@ from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT -from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -74,104 +68,3 @@ async def test_device_registry_update( ) assert gw_dev is not None assert gw_dev.sw_version == VERSION_NEW - - -# Device migration test can be removed in 2025.4.0 -async def test_device_migration( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the device registry is updated correctly.""" - mock_config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - identifiers={ - (DOMAIN, MOCK_GATEWAY_ID), - }, - name="Mock Gateway", - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - sw_version=VERSION_TEST, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert ( - device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) - is None - ) - - gw_dev = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} - ) - assert gw_dev is not None - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.BOILER}") - } - ) - is not None - ) - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.THERMOSTAT}") - } - ) - is not None - ) - - -# Entity migration test can be removed in 2025.4.0 -async def test_climate_entity_migration( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the climate entity unique_id gets migrated correctly.""" - mock_config_entry.add_to_hass(hass) - entry = entity_registry.async_get_or_create( - domain="climate", - platform="opentherm_gw", - unique_id=mock_config_entry.data[CONF_ID], - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - updated_entry = entity_registry.async_get(entry.entity_id) - assert updated_entry is not None - assert ( - updated_entry.unique_id - == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" - ) - - -# Deprecation test, can be removed in 2025.4.0 -async def test_configuration_yaml_deprecation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that existing configuration in configuration.yaml creates an issue.""" - - await setup.async_setup_component( - hass, DOMAIN, {DOMAIN: {"legacy_gateway": {"device": "/dev/null"}}} - ) - - await hass.async_block_till_done() - assert ( - issue_registry.async_get_issue( - DOMAIN, "deprecated_import_from_configuration_yaml" - ) - is not None - ) diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 61b68b5ad90..03b392b3e7b 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -39,6 +39,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "protection_window": { diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr new file mode 100644 index 00000000000..c89dcb96a9c --- /dev/null +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_minute_forecast[mock_service_response] + dict({ + 'weather.openweathermap': dict({ + 'forecast': list([ + dict({ + 'datetime': 1728672360, + 'precipitation': 0, + }), + dict({ + 'datetime': 1728672420, + 'precipitation': 1.23, + }), + dict({ + 'datetime': 1728672480, + 'precipitation': 4.5, + }), + dict({ + 'datetime': 1728672540, + 'precipitation': 0, + }), + ]), + }), + }) +# --- diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index aec34360754..d5e01677dd8 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -18,7 +18,7 @@ from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DEFAULT_OWM_MODE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_V30, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -40,13 +40,15 @@ CONFIG = { CONF_LATITUDE: 50, CONF_LONGITUDE: 40, CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_MODE: OWM_MODE_V25, + CONF_MODE: OWM_MODE_V30, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -def _create_mocked_owm_factory(is_valid: bool): +def _create_static_weather_report() -> WeatherReport: + """Create a static WeatherReport.""" + current_weather = CurrentWeather( date_time=datetime.fromtimestamp(1714063536, tz=UTC), temperature=6.84, @@ -60,8 +62,8 @@ def _create_mocked_owm_factory(is_valid: bool): wind_speed=9.83, wind_bearing=199, wind_gust=None, - rain={}, - snow={}, + rain={"1h": 1.21}, + snow=None, condition=WeatherCondition( id=803, main="Clouds", @@ -106,13 +108,21 @@ def _create_mocked_owm_factory(is_valid: bool): rain=0, snow=0, ) - minutely_weather_forecast = MinutelyWeatherForecast( - date_time=1728672360, precipitation=2.54 - ) - weather_report = WeatherReport( - current_weather, [minutely_weather_forecast], [], [daily_weather_forecast] + minutely_weather_forecast = [ + MinutelyWeatherForecast(date_time=1728672360, precipitation=0), + MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), + MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), + MinutelyWeatherForecast(date_time=1728672540, precipitation=0), + ] + return WeatherReport( + current_weather, minutely_weather_forecast, [], [daily_weather_forecast] ) + +def _create_mocked_owm_factory(is_valid: bool): + """Create a mocked OWM client.""" + + weather_report = _create_static_weather_report() mocked_owm_client = MagicMock() mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py new file mode 100644 index 00000000000..e9817e739ac --- /dev/null +++ b/tests/components/openweathermap/test_weather.py @@ -0,0 +1,121 @@ +"""Test the OpenWeatherMap weather entity.""" + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.openweathermap.const import ( + DEFAULT_LANGUAGE, + DOMAIN, + OWM_MODE_FREE_CURRENT, + OWM_MODE_V30, +) +from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .test_config_flow import _create_static_weather_report + +from tests.common import AsyncMock, MockConfigEntry, patch + +ENTITY_ID = "weather.openweathermap" +API_KEY = "test_api_key" +LATITUDE = 12.34 +LONGITUDE = 56.78 +NAME = "openweathermap" + +# Define test data for mocked weather report +static_weather_report = _create_static_weather_report() + + +def mock_config_entry(mode: str) -> MockConfigEntry: + """Create a mock OpenWeatherMap config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, + CONF_NAME: NAME, + }, + options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE}, + version=5, + ) + + +@pytest.fixture +def mock_config_entry_free_current() -> MockConfigEntry: + """Create a mock OpenWeatherMap FREE_CURRENT config entry.""" + return mock_config_entry(OWM_MODE_FREE_CURRENT) + + +@pytest.fixture +def mock_config_entry_v30() -> MockConfigEntry: + """Create a mock OpenWeatherMap v3.0 config entry.""" + return mock_config_entry(OWM_MODE_V30) + + +async def setup_mock_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +): + """Set up the MockConfigEntry and assert it is loaded correctly.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID) + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_get_minute_forecast( + hass: HomeAssistant, + mock_config_entry_v30: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_minute_forecast Service call.""" + await setup_mock_config_entry(hass, mock_config_entry_v30) + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert result == snapshot(name="mock_service_response") + + +@patch( + "pyopenweathermap.client.free_client.OWMFreeClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_mode_fail( + hass: HomeAssistant, + mock_config_entry_free_current: MockConfigEntry, +) -> None: + """Test that Minute forecasting fails when mode is not v3.0.""" + await setup_mock_config_entry(hass, mock_config_entry_free_current) + + # Expect a ServiceValidationError when mode is not OWM_MODE_V30 + with pytest.raises( + ServiceValidationError, + match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) diff --git a/tests/components/oralb/test_config_flow.py b/tests/components/oralb/test_config_flow.py index dee16cd0632..c4cc830b89c 100644 --- a/tests/components/oralb/test_config_flow.py +++ b/tests/components/oralb/test_config_flow.py @@ -96,6 +96,36 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.oralb.config_flow.async_discovered_service_info", + return_value=[ORALB_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.oralb.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "78:DB:2F:C2:48:BE"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Smart Series 7000 48BE" + assert result2["data"] == {} + assert result2["result"].unique_id == "78:DB:2F:C2:48:BE" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/osoenergy/snapshots/test_water_heater.ambr b/tests/components/osoenergy/snapshots/test_water_heater.ambr index 5ebac405144..92b3a7aa099 100644 --- a/tests/components/osoenergy/snapshots/test_water_heater.ambr +++ b/tests/components/osoenergy/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 7d52318b477..5f778169e55 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -33,6 +33,8 @@ TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") TEST_BORDER_AGENT_ID_2 = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52D") +COPROCESSOR_VERSION = "OPENTHREAD/thread-reference-20200818-1740-g33cc75ed3; NRF52840; Jun 2 2022 14:25:49" + ROUTER_DISCOVERY_HASS = { "type_": "_meshcop._udp.local.", "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", @@ -60,3 +62,7 @@ ROUTER_DISCOVERY_HASS = { }, "interface_index": None, } + +TEST_COPROCESSOR_VERSION = ( + "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57" +) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 5ab3e442183..9140fcf6847 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -15,6 +15,7 @@ from . import ( DATASET_CH16, TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, + TEST_COPROCESSOR_VERSION, ) from tests.common import MockConfigEntry @@ -71,12 +72,23 @@ def get_extended_address_fixture() -> Generator[AsyncMock]: yield get_extended_address +@pytest.fixture(name="get_coprocessor_version") +def get_coprocessor_version_fixture() -> Generator[AsyncMock]: + """Mock get_coprocessor_version.""" + with patch( + "python_otbr_api.OTBR.get_coprocessor_version", + return_value=TEST_COPROCESSOR_VERSION, + ) as get_coprocessor_version: + yield get_coprocessor_version + + @pytest.fixture(name="otbr_config_entry_multipan") async def otbr_config_entry_multipan_fixture( hass: HomeAssistant, get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> str: """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( @@ -97,6 +109,7 @@ async def otbr_config_entry_thread_fixture( get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> None: """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index cd02c14e4eb..8384b905b9c 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -10,9 +10,18 @@ import pytest import python_otbr_api from homeassistant.components import otbr +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.setup import async_setup_component from . import DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID, TEST_BORDER_AGENT_ID_2 @@ -32,6 +41,19 @@ HASSIO_DATA_2 = HassioServiceInfo( uuid="23456", ) +HASSIO_DATA_OTBR = HassioServiceInfo( + config={ + "host": "core-openthread-border-router", + "port": 8081, + "device": "/dev/ttyUSB1", + "firmware": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57\r", + "addon": "OpenThread Border Router", + }, + name="OpenThread Border Router", + slug="core_openthread_border_router", + uuid="c58ba80fc88548008776bf8da903ef21", +) + @pytest.fixture(name="otbr_addon_info") def otbr_addon_info_fixture(addon_info: AsyncMock, addon_installed) -> AsyncMock: @@ -97,6 +119,7 @@ async def test_user_flow_additional_entry( @pytest.mark.usefixtures( "get_active_dataset_tlvs", "get_extended_address", + "get_coprocessor_version", ) async def test_user_flow_additional_entry_fail_get_address( hass: HomeAssistant, @@ -174,6 +197,7 @@ async def _finish_user_flow( "get_active_dataset_tlvs", "get_border_agent_id", "get_extended_address", + "get_coprocessor_version", ) async def test_user_flow_additional_entry_same_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -563,7 +587,11 @@ async def test_hassio_discovery_flow_2x_addons( assert config_entry.unique_id == HASSIO_DATA_2.uuid -@pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address") +@pytest.mark.usefixtures( + "get_active_dataset_tlvs", + "get_extended_address", + "get_coprocessor_version", +) async def test_hassio_discovery_flow_2x_addons_same_ext_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: @@ -830,7 +858,9 @@ async def test_hassio_discovery_flow_new_port_missing_unique_id( # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -861,7 +891,9 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: # Setup the config entry config_entry = MockConfigEntry( data={ - "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + "url": ( + f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port'] + 1}" + ) }, domain=otbr.DOMAIN, options={}, @@ -897,7 +929,9 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Setup the config entry config_entry = MockConfigEntry( - data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + data={ + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}" + }, domain=otbr.DOMAIN, options={}, source="hassio", @@ -914,7 +948,7 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - # Make sure the data of the existing entry was not updated expected_data = { - "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port'] + 1}", } config_entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert config_entry.data == expected_data @@ -957,3 +991,55 @@ async def test_config_flow_additional_entry( ) assert result["type"] is expected_result + + +@pytest.mark.usefixtures( + "get_border_agent_id", "get_extended_address", "get_coprocessor_version" +) +async def test_hassio_discovery_reload( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info +) -> None: + """Test the hassio discovery flow.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + + aioclient_mock.get( + "http://core-openthread-border-router:8081/node/dataset/active", text="" + ) + + callback = Mock() + async_register_firmware_info_callback(hass, "/dev/ttyUSB1", callback) + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_OTBR + ) + + # OTBR is set up and calls the firmware info notification callback + assert len(callback.mock_calls) == 1 + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 + + # If we change discovery info and emit again, the integration will be reloaded + # and firmware information will be broadcast again + await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_OTBR + ) + + assert len(callback.mock_calls) == 2 + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 diff --git a/tests/components/otbr/test_homeassistant_hardware.py b/tests/components/otbr/test_homeassistant_hardware.py new file mode 100644 index 00000000000..7f831656d06 --- /dev/null +++ b/tests/components/otbr/test_homeassistant_hardware.py @@ -0,0 +1,254 @@ +"""Test Home Assistant Hardware platform for OTBR.""" + +from unittest.mock import AsyncMock, Mock, call, patch + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, + OwningIntegration, +) +from homeassistant.components.otbr.homeassistant_hardware import async_get_firmware_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from . import TEST_COPROCESSOR_VERSION + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +DEVICE_PATH = "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9ab1da1ea4b3ed11956f4eaca7669f5d-if00-port0" + + +async def test_get_firmware_info(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info`.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.return_value = TEST_COPROCESSOR_VERSION + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info == FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=TEST_COPROCESSOR_VERSION, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + + +async def test_get_firmware_info_ignored(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with ignored entry.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={}, + version=1, + ) + otbr.add_to_hass(hass) + + fw_info = await async_get_firmware_info(hass, otbr) + assert fw_info is None + + +async def test_get_firmware_info_no_coprocessor_version(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with no coprocessor version support.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.side_effect = HomeAssistantError() + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info == FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + + +@pytest.mark.parametrize( + ("version", "expected_version"), + [ + ((TEST_COPROCESSOR_VERSION,), TEST_COPROCESSOR_VERSION), + (HomeAssistantError(), None), + ], +) +async def test_hardware_firmware_info_provider_notification( + hass: HomeAssistant, + version: str | Exception, + expected_version: str | None, + get_active_dataset_tlvs: AsyncMock, + get_border_agent_id: AsyncMock, + get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that the OTBR provides hardware and firmware information.""" + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + + await async_setup_component(hass, "homeassistant_hardware", {}) + + callback = Mock() + async_register_firmware_info_callback(hass, DEVICE_PATH, callback) + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + get_coprocessor_version.side_effect = version + await hass.config_entries.async_setup(otbr.entry_id) + + assert callback.mock_calls == [ + call( + FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=expected_version, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + ) + ] + + +async def test_get_firmware_info_remote_otbr(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with no coprocessor version support.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://192.168.1.10:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.return_value = TEST_COPROCESSOR_VERSION + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=None, + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info is None diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index faf13786107..b14527165e6 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -26,6 +26,7 @@ from . import ( ROUTER_DISCOVERY_HASS, TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, + TEST_COPROCESSOR_VERSION, ) from tests.common import MockConfigEntry @@ -43,6 +44,7 @@ def enable_mocks_fixture( get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> None: """Enable API mocks.""" @@ -298,6 +300,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: mock_api.get_extended_address = AsyncMock( return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS ) + mock_api.get_coprocessor_version = AsyncMock(return_value=TEST_COPROCESSOR_VERSION) with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index cef5ef350a9..711cc6c1d86 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -17,11 +17,11 @@ from pyoverkiz.exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.overkiz.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -742,7 +742,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", @@ -801,7 +801,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="gateway-1234-5678-9123", ip="192.168.1.4", macaddress="f8811a000000", diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index b05d1d0cb87..9ae6be407ec 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -7,6 +7,7 @@ import pytest from python_overseerr import MovieDetails, RequestCount, RequestResponse from python_overseerr.models import TVDetails, WebhookNotificationConfig +from homeassistant.components.overseerr import CONF_CLOUDHOOK_URL from homeassistant.components.overseerr.const import DOMAIN from homeassistant.const import ( CONF_API_KEY, @@ -66,6 +67,24 @@ def mock_overseerr_client() -> Generator[AsyncMock]: yield client +@pytest.fixture +def mock_overseerr_client_needs_change( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.types = 0 + return mock_overseerr_client + + +@pytest.fixture +def mock_overseerr_client_cloudhook( + mock_overseerr_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock an Overseerr client.""" + mock_overseerr_client.get_webhook_notification_config.return_value.options.webhook_url = "https://hooks.nabu.casa/ABCD" + return mock_overseerr_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" @@ -81,3 +100,21 @@ def mock_config_entry() -> MockConfigEntry: }, entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", ) + + +@pytest.fixture +def mock_cloudhook_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Overseerr", + data={ + CONF_HOST: "overseerr.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "test-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + CONF_CLOUDHOOK_URL: "https://hooks.nabu.casa/ABCD", + }, + entry_id="01JG00V55WEVTJ0CJHM0GAD7PC", + ) diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index a4d07d6e9d3..2b3388444d2 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 222, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"event\":\"{{event}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdbId\":\"{{media_tmdbid}}\",\"tvdbId\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requestedBy_email\":\"{{requestedBy_email}}\",\"requestedBy_username\":\"{{requestedBy_username}}\",\"requestedBy_avatar\":\"{{requestedBy_avatar}}\",\"requestedBy_settings_discordId\":\"{{requestedBy_settings_discordId}}\",\"requestedBy_settings_telegramChatId\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reportedBy_email\":\"{{reportedBy_email}}\",\"reportedBy_username\":\"{{reportedBy_username}}\",\"reportedBy_avatar\":\"{{reportedBy_avatar}}\",\"reportedBy_settings_discordId\":\"{{reportedBy_settings_discordId}}\",\"reportedBy_settings_telegramChatId\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commentedBy_email\":\"{{commentedBy_email}}\",\"commentedBy_username\":\"{{commentedBy_username}}\",\"commentedBy_avatar\":\"{{commentedBy_avatar}}\",\"commentedBy_settings_discordId\":\"{{commentedBy_settings_discordId}}\",\"commentedBy_settings_telegramChatId\":\"{{commentedBy_settings_telegramChatId}}\"},\"{{extra}}\":[]\n}", + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_id\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" } } diff --git a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json index cc8795c9821..75059bcaf96 100644 --- a/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json +++ b/tests/components/overseerr/fixtures/webhook_request_automatically_approved.json @@ -1,25 +1,23 @@ { "notification_type": "MEDIA_AUTO_APPROVED", - "event": "Movie Request Automatically Approved", "subject": "Something (2024)", "message": "Here is an interesting Linux ISO that was automatically approved.", "image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg", "media": { "media_type": "movie", - "tmdbId": "123", - "tvdbId": "", + "tmdb_id": "123", + "tvdb_id": "", "status": "PENDING", "status4k": "UNKNOWN" }, "request": { "request_id": "16", - "requestedBy_email": "my@email.com", - "requestedBy_username": "henk", - "requestedBy_avatar": "https://plex.tv/users/abc/avatar?c=123", - "requestedBy_settings_discordId": "123", - "requestedBy_settings_telegramChatId": "" + "requested_by_email": "my@email.com", + "requested_by_username": "henk", + "requested_by_avatar": "https://plex.tv/users/abc/avatar?c=123", + "requested_by_settings_discord_id": "123", + "requested_by_settings_telegram_chat_id": "" }, "issue": null, - "comment": null, - "extra": [] + "comment": null } diff --git a/tests/components/overseerr/snapshots/test_diagnostics.ambr b/tests/components/overseerr/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..164257bb9f1 --- /dev/null +++ b/tests/components/overseerr/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics_polling_instance + dict({ + 'coordinator_data': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), + 'has_cloudhooks': False, + }) +# --- +# name: test_diagnostics_webhook_instance + dict({ + 'coordinator_data': dict({ + 'approved': 11, + 'available': 8, + 'declined': 0, + 'movie': 9, + 'pending': 0, + 'processing': 3, + 'total': 11, + 'tv': 2, + }), + 'has_cloudhooks': True, + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr new file mode 100644 index 00000000000..8a7be6c463d --- /dev/null +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -0,0 +1,84 @@ +# serializer version: 1 +# name: test_entities[event.overseerr_last_media_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.overseerr_last_media_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last media event', + 'platform': 'overseerr', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_media_event', + 'unique_id': '01JG00V55WEVTJ0CJHM0GAD7PC-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.overseerr_last_media_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://image.tmdb.org/t/p/w600_and_h900_bestv2/something.jpg', + 'event_type': 'auto_approved', + 'event_types': list([ + 'pending', + 'approved', + 'available', + 'failed', + 'declined', + 'auto_approved', + ]), + 'friendly_name': 'Overseerr Last media event', + 'media': dict({ + 'media_type': 'movie', + 'status': 'pending', + 'status4k': 'unknown', + 'tmdb_id': 123, + 'tvdb_id': None, + }), + 'message': 'Here is an interesting Linux ISO that was automatically approved.', + 'request': dict({ + 'request_id': 16, + 'requested_by_avatar': 'https://plex.tv/users/abc/avatar?c=123', + 'requested_by_email': 'my@email.com', + 'requested_by_settings_discord_id': '123', + 'requested_by_settings_telegram_chat_id': '', + 'requested_by_username': 'henk', + }), + 'subject': 'Something (2024)', + }), + 'context': , + 'entity_id': 'event.overseerr_last_media_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:00.000+00:00', + }) +# --- diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index 749a1aa445c..2709f532ef6 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -3,7 +3,8 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': None, + 'config_entries_subentries': , + 'configuration_url': 'http://overseerr.test', 'connections': set({ }), 'disabled_by': None, diff --git a/tests/components/overseerr/snapshots/test_sensor.ambr b/tests/components/overseerr/snapshots/test_sensor.ambr index 53a9b3dd82a..bbee260b782 100644 --- a/tests/components/overseerr/snapshots/test_sensor.ambr +++ b/tests/components/overseerr/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/overseerr/test_config_flow.py b/tests/components/overseerr/test_config_flow.py index 487c843ff1c..6a3b086a8e2 100644 --- a/tests/components/overseerr/test_config_flow.py +++ b/tests/components/overseerr/test_config_flow.py @@ -3,7 +3,10 @@ from unittest.mock import AsyncMock, patch import pytest -from python_overseerr.exceptions import OverseerrConnectionError +from python_overseerr.exceptions import ( + OverseerrAuthenticationError, + OverseerrConnectionError, +) from homeassistant.components.overseerr.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -61,13 +64,22 @@ async def test_full_flow( } +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) async def test_flow_errors( hass: HomeAssistant, mock_overseerr_client: AsyncMock, mock_setup_entry: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test flow errors.""" - mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError() + mock_overseerr_client.get_request_count.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, @@ -82,7 +94,7 @@ async def test_flow_errors( ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} mock_overseerr_client.get_request_count.side_effect = None @@ -143,3 +155,148 @@ async def test_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "new-test-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_API_KEY] == "new-test-key" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "overseerr2.test", + CONF_PORT: 80, + CONF_SSL: False, + CONF_API_KEY: "new-key", + CONF_WEBHOOK_ID: WEBHOOK_ID, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OverseerrAuthenticationError, "invalid_auth"), + (OverseerrConnectionError, "cannot_connect"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_overseerr_client.get_request_count.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_overseerr_client.get_request_count.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://overseerr2.test", CONF_API_KEY: "new-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/overseerr/test_diagnostics.py b/tests/components/overseerr/test_diagnostics.py new file mode 100644 index 00000000000..28b97e9514f --- /dev/null +++ b/tests/components/overseerr/test_diagnostics.py @@ -0,0 +1,47 @@ +"""Tests for the diagnostics data provided by the Overseerr integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_polling_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_diagnostics_webhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_overseerr_client_cloudhook: AsyncMock, + mock_cloudhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + await get_diagnostics_for_config_entry( + hass, hass_client, mock_cloudhook_config_entry + ) + == snapshot + ) diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py new file mode 100644 index 00000000000..3866ccc09ca --- /dev/null +++ b/tests/components/overseerr/test_event.py @@ -0,0 +1,170 @@ +"""Tests for the Overseerr event platform.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from future.backports.datetime import timedelta +import pytest +from python_overseerr import OverseerrConnectionError +from syrupy import SnapshotAssertion + +from homeassistant.components.overseerr import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_webhook, setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.overseerr.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2023-10-21") +async def test_event_does_not_write_state( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities don't write state on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_client_no_auth() + + await call_webhook( + hass, + load_json_object_fixture("webhook_request_automatically_approved.json", DOMAIN), + client, + ) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get( + "event.overseerr_last_media_event" + ).last_reported == datetime(2023, 10, 21, 0, 0, 0, tzinfo=UTC) + + +async def test_event_goes_unavailable( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event entities go unavailable when we can't fetch data.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("event.overseerr_last_media_event").state != STATE_UNAVAILABLE + ) + + mock_overseerr_client.get_request_count.side_effect = OverseerrConnectionError( + "Boom" + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) + + +async def test_not_push_based( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test event entities aren't created if not push based.""" + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is None + + +async def test_cant_fetch_webhook_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test event entities aren't created if not push based.""" + + mock_overseerr_client.get_webhook_notification_config.side_effect = ( + OverseerrConnectionError("Boom") + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is None + + +async def test_not_push_based_but_was_before( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test event entities are created if push based in the past.""" + + entity_registry.async_get_or_create( + Platform.EVENT, + DOMAIN, + f"{mock_config_entry.entry_id}-media", + suggested_object_id="overseerr_last_media_event", + disabled_by=None, + ) + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.overseerr_last_media_event") is not None + + assert ( + hass.states.get("event.overseerr_last_media_event").state == STATE_UNAVAILABLE + ) diff --git a/tests/components/overseerr/test_init.py b/tests/components/overseerr/test_init.py index 0962cd2c2f1..6418e2103db 100644 --- a/tests/components/overseerr/test_init.py +++ b/tests/components/overseerr/test_init.py @@ -1,20 +1,51 @@ """Tests for the Overseerr integration.""" from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest +from python_overseerr import OverseerrAuthenticationError, OverseerrConnectionError from python_overseerr.models import WebhookNotificationOptions from syrupy import SnapshotAssertion -from homeassistant.components.overseerr import JSON_PAYLOAD, REGISTERED_NOTIFICATIONS +from homeassistant.components import cloud +from homeassistant.components.cloud import CloudNotAvailable +from homeassistant.components.overseerr import ( + CONF_CLOUDHOOK_URL, + JSON_PAYLOAD, + REGISTERED_NOTIFICATIONS, +) from homeassistant.components.overseerr.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry +from tests.components.cloud import mock_cloud + + +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (OverseerrAuthenticationError, ConfigEntryState.SETUP_ERROR), + (OverseerrConnectionError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_initialization_errors( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test the Overseerr integration initialization errors.""" + mock_overseerr_client.get_request_count.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state == config_entry_state async def test_device_info( @@ -150,3 +181,232 @@ async def test_prefer_internal_ip( mock_overseerr_client.test_webhook_notification_config.call_args_list[1][0][0] == "https://www.example.com/api/webhook/test-webhook-id" ) + + +async def test_cloudhook_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_config_entry.data[CONF_CLOUDHOOK_URL] == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_cloudhook_consistent( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.side_effect = [ + False, + True, + ] + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 2 + ) + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_cloudhook_needs_no_change( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_cloudhook: AsyncMock, +) -> None: + """Test if we keep the cloudhook if it is already set up.""" + + await setup_integration(hass, mock_cloudhook_config_entry) + + assert ( + len(mock_overseerr_client_cloudhook.test_webhook_notification_config.mock_calls) + == 0 + ) + + +async def test_cloudhook_not_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test if we prefer local webhook over cloudhook.""" + + await hass.async_block_till_done() + + with ( + patch.object(cloud, "async_active_subscription", return_value=True), + ): + await setup_integration(hass, mock_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert CONF_CLOUDHOOK_URL not in mock_config_entry.data + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 1 + ) + assert ( + mock_overseerr_client_needs_change.test_webhook_notification_config.call_args_list[ + 0 + ][0][0] + == "http://10.10.10.10:8123/api/webhook/test-webhook-id" + ) + + +async def test_cloudhook_not_connecting( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client_needs_change: AsyncMock, +) -> None: + """Test the cloudhook is not registered if Overseerr cannot connect to it.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_overseerr_client_needs_change.test_webhook_notification_config.return_value = ( + False + ) + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + assert ( + mock_cloudhook_config_entry.data[CONF_CLOUDHOOK_URL] + == "https://hooks.nabu.casa/ABCD" + ) + + assert ( + len( + mock_overseerr_client_needs_change.test_webhook_notification_config.mock_calls + ) + == 3 + ) + + mock_overseerr_client_needs_change.set_webhook_notification_config.assert_not_called() + + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, + mock_cloudhook_config_entry: MockConfigEntry, + mock_overseerr_client: AsyncMock, +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), + ): + await setup_integration(hass, mock_cloudhook_config_entry) + + assert cloud.async_active_subscription(hass) is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/p1_monitor/snapshots/test_init.ambr b/tests/components/p1_monitor/snapshots/test_init.ambr index d0a676fce1b..83684e153c9 100644 --- a/tests/components/p1_monitor/snapshots/test_init.ambr +++ b/tests/components/p1_monitor/snapshots/test_init.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, @@ -38,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, diff --git a/tests/components/palazzetti/conftest.py b/tests/components/palazzetti/conftest.py index fad535df914..d3694653cd4 100644 --- a/tests/components/palazzetti/conftest.py +++ b/tests/components/palazzetti/conftest.py @@ -79,7 +79,12 @@ def mock_palazzetti_client() -> Generator[AsyncMock]: mock_client.target_temperature_max = 50 mock_client.pellet_quantity = 1248 mock_client.pellet_level = 0 + mock_client.has_second_fan = True + mock_client.has_second_fan = False mock_client.fan_speed = 3 + mock_client.current_fan_speed.return_value = 3 + mock_client.min_fan_speed.return_value = 0 + mock_client.max_fan_speed.return_value = 5 mock_client.connect.return_value = True mock_client.update_state.return_value = True mock_client.set_on.return_value = True diff --git a/tests/components/palazzetti/snapshots/test_button.ambr b/tests/components/palazzetti/snapshots/test_button.ambr new file mode 100644 index 00000000000..8130f0a0ec7 --- /dev/null +++ b/tests/components/palazzetti/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[button.stove_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.stove_silent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silent', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'silent', + 'unique_id': '11:22:33:44:55:66-silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.stove_silent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stove Silent', + }), + 'context': , + 'entity_id': 'button.stove_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index e7cea3749a1..cf23cb87ccb 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -6,7 +6,6 @@ 'area_id': None, 'capabilities': dict({ 'fan_modes': list([ - 'silent', '1', '2', '3', @@ -24,6 +23,7 @@ 'target_temp_step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,7 +56,6 @@ 'current_temperature': 18, 'fan_mode': '3', 'fan_modes': list([ - 'silent', '1', '2', '3', diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index abdee6b7f6f..fc96cab4fad 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr index 0a25a1cfa8b..1d40e9e4b6b 100644 --- a/tests/components/palazzetti/snapshots/test_number.ambr +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,3 +56,117 @@ 'state': '3', }) # --- +# name: test_all_entities[number.stove_left_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_left_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Left fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_left_speed', + 'unique_id': '11:22:33:44:55:66-fan_left_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_left_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Left fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_left_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.stove_right_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Right fan speed', + 'platform': 'palazzetti', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_right_speed', + 'unique_id': '11:22:33:44:55:66-fan_right_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.stove_right_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Stove Right fan speed', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.stove_right_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/palazzetti/snapshots/test_sensor.ambr b/tests/components/palazzetti/snapshots/test_sensor.ambr index aa98f3a4f59..6bf4f68c1fa 100644 --- a/tests/components/palazzetti/snapshots/test_sensor.ambr +++ b/tests/components/palazzetti/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +368,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -460,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -511,6 +519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/palazzetti/test_button.py b/tests/components/palazzetti/test_button.py new file mode 100644 index 00000000000..de0f26fe8aa --- /dev/null +++ b/tests/components/palazzetti/test_button.py @@ -0,0 +1,69 @@ +"""Tests for the Palazzetti button platform.""" + +from unittest.mock import AsyncMock, patch + +from pypalazzetti.exceptions import CommunicationError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "button.stove_silent" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.palazzetti.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_press( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing via service call.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_palazzetti_client.set_fan_silent.assert_called_once() + + +async def test_async_press_error( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing with error via service call.""" + await setup_integration(hass, mock_config_entry) + + mock_palazzetti_client.set_fan_silent.side_effect = CommunicationError() + error_message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) diff --git a/tests/components/palazzetti/test_climate.py b/tests/components/palazzetti/test_climate.py index 78af8f00bdb..22bd04f234e 100644 --- a/tests/components/palazzetti/test_climate.py +++ b/tests/components/palazzetti/test_climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH, FAN_SILENT +from homeassistant.components.palazzetti.const import FAN_AUTO, FAN_HIGH from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -118,15 +118,6 @@ async def test_async_set_data( ) # Set Fan Mode: Success - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_SILENT}, - blocking=True, - ) - mock_palazzetti_client.set_fan_silent.assert_called_once() - mock_palazzetti_client.set_fan_silent.reset_mock() - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 03c56c33d0c..8550f1a3de0 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock from pypalazzetti.exceptions import CommunicationError -from homeassistant.components import dhcp from homeassistant.components.palazzetti.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -101,7 +101,7 @@ async def test_dhcp_flow( """Test the DHCP flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, @@ -130,7 +130,7 @@ async def test_dhcp_flow_error( result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" ), context={"source": SOURCE_DHCP}, diff --git a/tests/components/palazzetti/test_number.py b/tests/components/palazzetti/test_number.py index 939c7c72c19..8f09384c1b7 100644 --- a/tests/components/palazzetti/test_number.py +++ b/tests/components/palazzetti/test_number.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from pypalazzetti.exceptions import CommunicationError, ValidationError +from pypalazzetti.fan import FanType import pytest from syrupy import SnapshotAssertion @@ -16,7 +17,8 @@ from . import setup_integration from tests.common import MockConfigEntry, snapshot_platform -ENTITY_ID = "number.stove_combustion_power" +POWER_ENTITY_ID = "number.stove_combustion_power" +FAN_ENTITY_ID = "number.stove_left_fan_speed" async def test_all_entities( @@ -33,7 +35,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_async_set_data( +async def test_async_set_data_power( hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -45,28 +47,71 @@ async def test_async_set_data( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, blocking=True, ) mock_palazzetti_client.set_power_mode.assert_called_once_with(1) - mock_palazzetti_client.set_on.reset_mock() + mock_palazzetti_client.set_power_mode.reset_mock() # Set value: Error mock_palazzetti_client.set_power_mode.side_effect = CommunicationError() - with pytest.raises(HomeAssistantError): + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_power_mode.reset_mock() + + mock_palazzetti_client.set_power_mode.side_effect = ValidationError() + message = "Combustion power 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: POWER_ENTITY_ID, "value": 1}, + blocking=True, + ) + + +async def test_async_set_data_fan( + hass: HomeAssistant, + mock_palazzetti_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting number data via service call.""" + await setup_integration(hass, mock_config_entry) + + # Set value: Success + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, + blocking=True, + ) + mock_palazzetti_client.set_fan_speed.assert_called_once_with(1, FanType.LEFT) + mock_palazzetti_client.set_on.reset_mock() + + # Set value: Error + mock_palazzetti_client.set_fan_speed.side_effect = CommunicationError() + message = "Could not connect to the device" + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, blocking=True, ) mock_palazzetti_client.set_on.reset_mock() - mock_palazzetti_client.set_power_mode.side_effect = ValidationError() - with pytest.raises(ServiceValidationError): + mock_palazzetti_client.set_fan_speed.side_effect = ValidationError() + message = "Fan left speed 1.0 is invalid" + with pytest.raises(ServiceValidationError, match=message): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ENTITY_ID, "value": 1}, + {ATTR_ENTITY_ID: FAN_ENTITY_ID, "value": 1}, blocking=True, ) diff --git a/tests/components/peblar/snapshots/test_binary_sensor.ambr b/tests/components/peblar/snapshots/test_binary_sensor.ambr index 72c3ac78a12..9ad9c877ed2 100644 --- a/tests/components/peblar/snapshots/test_binary_sensor.ambr +++ b/tests/components/peblar/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_button.ambr b/tests/components/peblar/snapshots/test_button.ambr index 96aab5c93ef..6d31da0ae52 100644 --- a/tests/components/peblar/snapshots/test_button.ambr +++ b/tests/components/peblar/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index e33a2f557de..fbcdcfbaff5 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -51,10 +51,8 @@ 'Hostname': 'PBLR-0000645', 'HwFixedCableRating': 20, 'HwFwCompat': 'wlac-2', - 'HwHas4pRelay': False, 'HwHasBop': True, 'HwHasBuzzer': True, - 'HwHasDualSocket': False, 'HwHasEichrechtLaserMarking': False, 'HwHasEthernet': True, 'HwHasLed': True, @@ -64,13 +62,11 @@ 'HwHasPlc': False, 'HwHasRfid': True, 'HwHasRs485': True, - 'HwHasShutter': False, 'HwHasSocket': False, 'HwHasTpm': False, 'HwHasWlan': True, 'HwMaxCurrent': 16, 'HwOneOrThreePhase': 3, - 'HwUKCompliant': False, 'MainboardPn': '6004-2300-7600', 'MainboardSn': '23-38-A4E-2MC', 'MeterCalIGainA': 267369, @@ -86,7 +82,6 @@ 'MeterCalVGainB': 246074, 'MeterCalVGainC': 230191, 'MeterFwIdent': 'b9cbcd', - 'NorFlash': 'True', 'ProductModelName': 'WLAC1-H11R0WE0ICR00', 'ProductPn': '6004-2300-8002', 'ProductSn': '23-45-A4O-MOF', diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index ba79093b3ec..8a7cefc523d 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.127', 'connections': set({ tuple( diff --git a/tests/components/peblar/snapshots/test_number.ambr b/tests/components/peblar/snapshots/test_number.ambr index d78067849f3..d8e9c756c50 100644 --- a/tests/components/peblar/snapshots/test_number.ambr +++ b/tests/components/peblar/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_select.ambr b/tests/components/peblar/snapshots/test_select.ambr index 62e09325601..3a600653a84 100644 --- a/tests/components/peblar/snapshots/test_select.ambr +++ b/tests/components/peblar/snapshots/test_select.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr index da17a4661ee..5a1d1663ba2 100644 --- a/tests/components/peblar/snapshots/test_sensor.ambr +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,7 +307,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', @@ -311,6 +316,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -354,7 +360,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', @@ -379,6 +385,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,6 +437,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -532,6 +541,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -583,6 +593,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -648,6 +659,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +716,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -753,6 +766,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -804,6 +818,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -855,6 +870,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr index 53829278593..46051974339 100644 --- a/tests/components/peblar/snapshots/test_switch.ambr +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_entities[switch][switch.peblar_ev_charger_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.peblar_ev_charger_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge', + 'unique_id': '23-45-A4O-MOF_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch][switch.peblar_ev_charger_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Charge', + }), + 'context': , + 'entity_id': 'switch.peblar_ev_charger_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_update.ambr b/tests/components/peblar/snapshots/test_update.ambr index de8bb63150d..0a6b2bf069f 100644 --- a/tests/components/peblar/snapshots/test_update.ambr +++ b/tests/components/peblar/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py index a97e8d3b564..9f0806f0591 100644 --- a/tests/components/peblar/test_config_flow.py +++ b/tests/components/peblar/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import MagicMock from peblar import PeblarAuthenticationError, PeblarConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -232,7 +232,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -273,7 +273,7 @@ async def test_zeroconf_flow_abort_no_serial(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -308,7 +308,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -362,7 +362,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -389,7 +389,7 @@ async def test_user_flow_with_zeroconf_in_progress(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py index 57469fecbc6..fa49b6ab116 100644 --- a/tests/components/peblar/test_number.py +++ b/tests/components/peblar/test_number.py @@ -14,18 +14,19 @@ from homeassistant.components.number import ( from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform - -pytestmark = [ - pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True), - pytest.mark.usefixtures("init_integration"), -] +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -48,7 +49,8 @@ async def test_entities( assert entity_entry.device_id == device_entry.id -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value( hass: HomeAssistant, mock_peblar: MagicMock, @@ -73,6 +75,43 @@ async def test_number_set_value( mocked_method.mock_calls[0].assert_called_with({"charge_current_limit": 10}) +async def test_number_set_value_when_charging_is_suspended( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of setting the charging limit while charging is suspended.""" + entity_id = "number.peblar_ev_charger_charge_limit" + + # Suspend charging + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + # Test normal happy path number value change + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 0 + + # Check the state is reflected + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + + @pytest.mark.parametrize( ("error", "error_match", "translation_key", "translation_placeholders"), [ @@ -96,7 +135,8 @@ async def test_number_set_value( ), ], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value_communication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -128,6 +168,8 @@ async def test_number_set_value_communication_error( assert excinfo.value.translation_placeholders == translation_placeholders +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_number_set_value_authentication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -175,3 +217,51 @@ async def test_number_set_value_authentication_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("restore_state", "restore_native_value", "expected_state"), + [ + ("10", 10, "10"), + ("unknown", 10, "unknown"), + ("unavailable", 10, "unknown"), + ("10", None, "unknown"), + ], +) +async def test_restore_state( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + restore_state: str, + restore_native_value: int, + expected_state: str, +) -> None: + """Test restoring the number state.""" + EXTRA_STORED_DATA = { + "native_max_value": 16, + "native_min_value": 6, + "native_step": 1, + "native_unit_of_measurement": "A", + "native_value": restore_native_value, + } + mock_restore_cache_with_extra_data( + hass, + ( + ( + State("number.peblar_ev_charger_charge_limit", restore_state), + EXTRA_STORED_DATA, + ), + ), + ) + + # Adjust Peblar client to have an ignored value for the charging limit + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check if state is restored and value is set correctly + assert (state := hass.states.get("number.peblar_ev_charger_charge_limit")) + assert state.state == expected_state diff --git a/tests/components/peblar/test_switch.py b/tests/components/peblar/test_switch.py index 75deeb2d5d3..a7dab51eb3a 100644 --- a/tests/components/peblar/test_switch.py +++ b/tests/components/peblar/test_switch.py @@ -49,10 +49,32 @@ async def test_entities( @pytest.mark.parametrize( - ("service", "force_single_phase"), + ("service", "entity_id", "parameter", "parameter_value"), [ - (SERVICE_TURN_ON, True), - (SERVICE_TURN_OFF, False), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + True, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + False, + ), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 16, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 0, + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -60,10 +82,11 @@ async def test_switch( hass: HomeAssistant, mock_peblar: MagicMock, service: str, - force_single_phase: bool, + entity_id: str, + parameter: str, + parameter_value: bool | int, ) -> None: """Test the Peblar EV charger switches.""" - entity_id = "switch.peblar_ev_charger_force_single_phase" mocked_method = mock_peblar.rest_api.return_value.ev_interface mocked_method.reset_mock() @@ -76,9 +99,7 @@ async def test_switch( ) assert len(mocked_method.mock_calls) == 2 - mocked_method.mock_calls[0].assert_called_with( - {"force_single_phase": force_single_phase} - ) + mocked_method.mock_calls[0].assert_called_with({parameter: parameter_value}) @pytest.mark.parametrize( diff --git a/tests/components/pegel_online/snapshots/test_diagnostics.ambr b/tests/components/pegel_online/snapshots/test_diagnostics.ambr index 1e55805f867..d0fdc81acb4 100644 --- a/tests/components/pegel_online/snapshots/test_diagnostics.ambr +++ b/tests/components/pegel_online/snapshots/test_diagnostics.ambr @@ -31,6 +31,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '70272185-xxxx-xxxx-xxxx-43bea330dcae', 'version': 1, diff --git a/tests/components/persistent_notification/conftest.py b/tests/components/persistent_notification/conftest.py index 29ba5a6008a..76fdc70ea7b 100644 --- a/tests/components/persistent_notification/conftest.py +++ b/tests/components/persistent_notification/conftest.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 956183d8420..89559d45dc4 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,6 +1,6 @@ """The tests for the persistent notification component.""" -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_trigger.py b/tests/components/persistent_notification/test_trigger.py index 16208143447..5e03fbf5f19 100644 --- a/tests/components/persistent_notification/test_trigger.py +++ b/tests/components/persistent_notification/test_trigger.py @@ -2,7 +2,7 @@ from typing import Any -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.persistent_notification import trigger from homeassistant.core import Context, HomeAssistant, callback diff --git a/tests/components/pglab/__init__.py b/tests/components/pglab/__init__.py new file mode 100644 index 00000000000..0ee9b203524 --- /dev/null +++ b/tests/components/pglab/__init__.py @@ -0,0 +1 @@ +"""Tests for the PG LAB Electronics integration.""" diff --git a/tests/components/pglab/conftest.py b/tests/components/pglab/conftest.py new file mode 100644 index 00000000000..b148cb08a15 --- /dev/null +++ b/tests/components/pglab/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the PG LAB Electronics tests.""" + +import pytest + +from homeassistant.components.pglab.const import DISCOVERY_TOPIC, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + +CONF_DISCOVERY_PREFIX = "discovery_prefix" + + +@pytest.fixture +def device_reg(hass: HomeAssistant): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass: HomeAssistant): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +async def setup_pglab(hass: HomeAssistant): + """Set up PG LAB Electronics.""" + hass.config.components.add("pglab") + + entry = MockConfigEntry( + data={CONF_DISCOVERY_PREFIX: DISCOVERY_TOPIC}, + domain=DOMAIN, + title="PG LAB Electronics", + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "pglab" in hass.config.components diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f25f459bb70 --- /dev/null +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_sensors[mpu_voltage][initial_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.31', + }) +# --- +# name: test_sensors[run_time][initial_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[run_time][updated_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-26T01:04:54+00:00', + }) +# --- +# name: test_sensors[temperature][initial_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[temperature][updated_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.4', + }) +# --- diff --git a/tests/components/pglab/test_config_flow.py b/tests/components/pglab/test_config_flow.py new file mode 100644 index 00000000000..81ed010920e --- /dev/null +++ b/tests/components/pglab/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test the PG LAB Electronics config flow.""" + +from homeassistant.components.mqtt import MQTT_CONNECTION_STATE +from homeassistant.components.pglab.const import DOMAIN +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient + + +async def test_mqtt_config_single_instance( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test MQTT flow aborts when an entry already exist.""" + + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT} + ) + + # Be sure that result is abort. Only single instance is allowed. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="pglab/discovery/E-Board-DD53AC85/config", + payload=( + '{"ip":"192.168.1.16", "mac":"80:34:28:1B:18:5A", "name":"e-board-office",' + '"hw":"255.255.255", "fw":"255.255.255", "type":"E-Board", "id":"E-Board-DD53AC85",' + '"manufacturer":"PG LAB Electronics", "params":{"shutters":0, "boards":"10000000" } }' + ), + qos=0, + retain=False, + subscribed_topic="pglab/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].data == {"discovery_prefix": "pglab/discovery"} + + +async def test_mqtt_abort_invalid_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Check MQTT flow aborts if discovery topic is invalid.""" + discovery_info = MqttServiceInfo( + topic="pglab/discovery/E-Board-DD53AC85/wrong_topic", + payload=( + '{"ip":"192.168.1.16", "mac":"80:34:28:1B:18:5A", "name":"e-board-office",' + '"hw":"255.255.255", "fw":"255.255.255", "type":"E-Board", "id":"E-Board-DD53AC85",' + '"manufacturer":"PG LAB Electronics", "params":{"shutters":0, "boards":"10000000" } }' + ), + qos=0, + retain=False, + subscribed_topic="pglab/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + discovery_info = MqttServiceInfo( + topic="pglab/discovery/E-Board-DD53AC85/config", + payload="", + qos=0, + retain=False, + subscribed_topic="pglab/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +async def test_user_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test if the user can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].data == { + "discovery_prefix": "pglab/discovery", + } + + +async def test_user_setup_mqtt_not_connected( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that the user setup is aborted when MQTT is not connected.""" + + mqtt_mock.connected = False + async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_connected" + + +async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None: + """Test that the user setup is aborted when MQTT is not configured.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_configured" diff --git a/tests/components/pglab/test_discovery.py b/tests/components/pglab/test_discovery.py new file mode 100644 index 00000000000..65716236277 --- /dev/null +++ b/tests/components/pglab/test_discovery.py @@ -0,0 +1,154 @@ +"""The tests for the PG LAB Electronics discovery device.""" + +import json + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_device_discover( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_reg, + entity_reg, + setup_pglab, +) -> None: + """Test setting up a device.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device and registry entries are created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + assert device_entry.configuration_url == f"http://{payload['ip']}/" + assert device_entry.manufacturer == "PG LAB Electronics" + assert device_entry.model == payload["type"] + assert device_entry.name == payload["name"] + assert device_entry.sw_version == payload["fw"] + + +async def test_device_update( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_reg, + entity_reg, + setup_pglab, + snapshot: SnapshotAssertion, +) -> None: + """Test update a device.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device is created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + + # update device + payload["fw"] = "1.0.1" + payload["hw"] = "1.0.8" + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device is created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + assert device_entry.sw_version == "1.0.1" + assert device_entry.hw_version == "1.0.8" + + +async def test_device_remove( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_reg, + entity_reg, + setup_pglab, +) -> None: + """Test remove a device.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device is created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + + async_fire_mqtt_message( + hass, + topic, + "", + ) + await hass.async_block_till_done() + + # Verify device entry is removed + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is None diff --git a/tests/components/pglab/test_init.py b/tests/components/pglab/test_init.py new file mode 100644 index 00000000000..a6353054e8c --- /dev/null +++ b/tests/components/pglab/test_init.py @@ -0,0 +1 @@ +"""Test the PG LAB Electronics integration.""" diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py new file mode 100644 index 00000000000..ff20d1452a4 --- /dev/null +++ b/tests/components/pglab/test_sensor.py @@ -0,0 +1,71 @@ +"""The tests for the PG LAB Electronics sensor.""" + +import json + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def send_discovery_message(hass: HomeAssistant) -> None: + """Send mqtt discovery message.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "00000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + +@freeze_time("2024-02-26 01:21:34") +@pytest.mark.parametrize( + "sensor_suffix", + [ + "temperature", + "mpu_voltage", + "run_time", + ], +) +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mqtt_mock: MqttMockHAClient, + setup_pglab, + sensor_suffix: str, +) -> None: + """Check if sensors are properly created and updated.""" + + # send the discovery message to make E-BOARD device discoverable + await send_discovery_message(hass) + + # check initial sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"initial_sensor_{sensor_suffix}") + + # update sensors value via mqtt + update_payload = {"temp": 33.4, "volt": 3.31, "rtime": 1000} + async_fire_mqtt_message(hass, "pglab/test/sensor/value", json.dumps(update_payload)) + await hass.async_block_till_done() + + # check updated sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"updated_sensor_{sensor_suffix}") diff --git a/tests/components/pglab/test_switch.py b/tests/components/pglab/test_switch.py new file mode 100644 index 00000000000..fef445f80f3 --- /dev/null +++ b/tests/components/pglab/test_switch.py @@ -0,0 +1,318 @@ +"""The tests for the PG LAB Electronics switch.""" + +from datetime import timedelta +import json + +from homeassistant import config_entries +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.typing import MqttMockHAClient + + +async def call_service(hass: HomeAssistant, entity_id, service, **kwargs): + """Call a service.""" + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **kwargs}, + blocking=True, + ) + + +async def test_available_relay( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Check if relay are properly created when two E-Relay boards are connected.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + for i in range(16): + state = hass.states.get(f"switch.test_relay_{i}") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_change_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test state update via MQTT.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Simulate response from the device + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Turn relay OFF + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "OFF") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert state.state == STATE_OFF + + # Turn relay ON + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_ON + + # Turn relay OFF + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "OFF") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_OFF + + # Turn relay ON + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_ON + + +async def test_mqtt_state_by_calling_service( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Calling service to turn ON/OFF relay and check mqtt state.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Turn relay ON + await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/0/set", "ON", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Turn relay OFF + await call_service(hass, "switch.test_relay_0", SERVICE_TURN_OFF) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/0/set", "OFF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Turn relay ON + await call_service(hass, "switch.test_relay_3", SERVICE_TURN_ON) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/3/set", "ON", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Turn relay OFF + await call_service(hass, "switch.test_relay_3", SERVICE_TURN_OFF) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/3/set", "OFF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + +async def test_discovery_update( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Update discovery message and check if relay are property updated.""" + + # publish the first discovery message + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "first_test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # test the available relay in the first configuration + for i in range(8): + state = hass.states.get(f"switch.first_test_relay_{i}") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # prepare a new message ... the same device but renamed + # and with different relay configuration + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "second_test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # be sure that old relay are been removed + for i in range(8): + assert not hass.states.get(f"switch.first_test_relay_{i}") + + # check new relay + for i in range(16): + state = hass.states.get(f"switch.second_test_relay_{i}") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_disable_entity_state_change_via_mqtt( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mqtt_mock: MqttMockHAClient, + setup_pglab, +) -> None: + """Test state update via MQTT of disable entity.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Be sure that the entity relay_0 is available + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Disable entity relay_0 + new_status = entity_registry.async_update_entity( + "switch.test_relay_0", disabled_by=er.RegistryEntryDisabler.USER + ) + + # Be sure that the entity is disabled + assert new_status.disabled is True + + # Try to change the state of the disabled relay_0 + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + + # Enable entity relay_0 + new_status = entity_registry.async_update_entity( + "switch.test_relay_0", disabled_by=None + ) + + # Be sure that the entity is enabled + assert new_status.disabled is False + + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Re-send the discovery message + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Be sure that the state is not changed + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_UNKNOWN + + # Try again to change the state of the disabled relay_0 + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + + # Be sure that the state is been updated + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_ON diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr index 4f7a6176634..53db95f0534 100644 --- a/tests/components/philips_js/snapshots/test_diagnostics.ambr +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -94,6 +94,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 80d05961813..4b8048a8ebe 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -155,6 +155,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "version": 1, "options": {}, "minor_version": 1, + "subentries": (), } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 3094fcef24b..2d6f6687d04 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -33,6 +33,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index 8d668b28c16..ba4c36682e1 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2.session import PicnicAuthError import requests from homeassistant import config_entries diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index 0196c2cbbfb..bb28432841f 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index d1548f7559c..bb811af6a34 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ping/test_helpers.py b/tests/components/ping/test_helpers.py new file mode 100644 index 00000000000..5a90c6b75b2 --- /dev/null +++ b/tests/components/ping/test_helpers.py @@ -0,0 +1,59 @@ +"""Test the exception handling in subprocess version of async_ping.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.ping.helpers import PingDataSubProcess +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +class MockAsyncSubprocess: + """Minimal mock implementation of asyncio.subprocess.Process for exception testing.""" + + def __init__(self, killsig=ProcessLookupError, **kwargs) -> None: + """Store provided exception type for later.""" + self.killsig = killsig + + async def communicate(self) -> None: + """Fails immediately with a timeout.""" + raise TimeoutError + + async def kill(self) -> None: + """Raise preset exception when called.""" + raise self.killsig + + +@pytest.mark.parametrize("exc", [TypeError, ProcessLookupError]) +async def test_async_ping_expected_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + exc: Exception, +) -> None: + """Test PingDataSubProcess.async_ping handles expected exceptions.""" + with patch( + "asyncio.create_subprocess_exec", return_value=MockAsyncSubprocess(killsig=exc) + ): + # Actual parameters irrelevant, as subprocess will not be created + ping = PingDataSubProcess(hass, host="10.10.10.10", count=3, privileged=False) + assert await ping.async_ping() is None + + +async def test_async_ping_unexpected_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test PingDataSubProcess.async_ping does not suppress unexpected exceptions.""" + with patch( + "asyncio.create_subprocess_exec", + return_value=MockAsyncSubprocess(killsig=KeyboardInterrupt), + ): + # Actual parameters irrelevant, as subprocess will not be created + ping = PingDataSubProcess(hass, host="10.10.10.10", count=3, privileged=False) + with pytest.raises(KeyboardInterrupt): + assert await ping.async_ping() is None diff --git a/tests/components/plaato/snapshots/test_binary_sensor.ambr b/tests/components/plaato/snapshots/test_binary_sensor.ambr index e8db3bf32d8..76c0a299c5e 100644 --- a/tests/components/plaato/snapshots/test_binary_sensor.ambr +++ b/tests/components/plaato/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/plaato/snapshots/test_sensor.ambr b/tests/components/plaato/snapshots/test_sensor.ambr index 110ffb04ba9..24ba62e28ca 100644 --- a/tests/components/plaato/snapshots/test_sensor.ambr +++ b/tests/components/plaato/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +387,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -478,6 +488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -528,6 +539,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/plex/helpers.py b/tests/components/plex/helpers.py index 434c31996e4..4dc80d3e7aa 100644 --- a/tests/components/plex/helpers.py +++ b/tests/components/plex/helpers.py @@ -7,7 +7,7 @@ from plexwebsocket import SIGNAL_CONNECTION_STATE, STATE_CONNECTED from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c4ec108bb6b..42dcf449168 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -523,7 +523,7 @@ async def test_callback_view( assert result["type"] is FlowResultType.EXTERNAL_STEP client = await hass_client_no_auth() - forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' + forward_url = f"{config_flow.AUTH_CALLBACK_PATH}?flow_id={result['flow_id']}" resp = await client.get(forward_url) assert resp.status == HTTPStatus.OK diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 490091998ff..036b2d87f3f 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_DATA, DEFAULT_OPTIONS, PLEX_DIRECT_URL from .helpers import trigger_plex_update, wait_for_debouncer diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index e0ada8ea849..e0a61106101 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from packaging.version import Version -from plugwise import PlugwiseData import pytest from homeassistant.components.plugwise.const import DOMAIN @@ -30,6 +29,51 @@ def _read_json(environment: str, call: str) -> dict[str, Any]: return json.loads(fixture) +@pytest.fixture +def cooling_present(request: pytest.FixtureRequest) -> str: + """Pass the cooling_present boolean. + + Used with fixtures that require parametrization of the cooling capability. + """ + return request.param + + +@pytest.fixture +def chosen_env(request: pytest.FixtureRequest) -> str: + """Pass the chosen_env string. + + Used with fixtures that require parametrization of the user-data fixture. + """ + return request.param + + +@pytest.fixture +def gateway_id(request: pytest.FixtureRequest) -> str: + """Pass the gateway_id string. + + Used with fixtures that require parametrization of the gateway_id. + """ + return request.param + + +@pytest.fixture +def heater_id(request: pytest.FixtureRequest) -> str: + """Pass the heater_idstring. + + Used with fixtures that require parametrization of the heater_id. + """ + return request.param + + +@pytest.fixture +def reboot(request: pytest.FixtureRequest) -> str: + """Pass the reboot boolean. + + Used with fixtures that require parametrization of the reboot capability. + """ + return request.param + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -64,11 +108,14 @@ def mock_smile_config_flow() -> Generator[MagicMock]: autospec=True, ) as smile_mock: smile = smile_mock.return_value + + smile.connect.return_value = Version("4.3.2") smile.smile_hostname = "smile12345" smile.smile_model = "Test Model" smile.smile_model_id = "Test Model ID" smile.smile_name = "Test Smile Name" - smile.connect.return_value = Version("4.3.2") + smile.smile_version = "4.3.2" + yield smile @@ -76,7 +123,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_multiple_devices_per_zone" - + data = _read_json(chosen_env, "data") with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True @@ -88,230 +135,120 @@ def mock_smile_adam() -> Generator[MagicMock]: ): smile = smile_mock.return_value + smile.async_update.return_value = data + smile.cooling_present = False + smile.connect.return_value = Version("3.0.15") smile.gateway_id = "fe799307f1624099878210aa0b9f1475" smile.heater_id = "90986d591dcd426cae3ec3e8111ff730" + smile.reboot = True + smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" + smile.smile_model_id = "smile_open_therm" + smile.smile_name = "Adam" + smile.smile_type = "thermostat" smile.smile_version = "3.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.connect.return_value = Version("3.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) yield smile @pytest.fixture -def mock_smile_adam_2() -> Generator[MagicMock]: - """Create a 2nd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_heating" - +def mock_smile_adam_heat_cool( + chosen_env: str, cooling_present: bool +) -> Generator[MagicMock]: + """Create a special base Mock Adam type for testing with different datasets.""" + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.6.4") + smile.cooling_present = cooling_present smile.gateway_id = "da224107914542988a88561b4452b0f6" smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "3.6.4" yield smile @pytest.fixture -def mock_smile_adam_3() -> Generator[MagicMock]: - """Create a 3rd Mock Adam environment for testing exceptions.""" - chosen_env = "m_adam_cooling" - - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "da224107914542988a88561b4452b0f6" - smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.connect.return_value = Version("3.6.4") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_adam_4() -> Generator[MagicMock]: - """Create a 4th Mock Adam environment for testing exceptions.""" +def mock_smile_adam_jip() -> Generator[MagicMock]: + """Create a Mock adam-jip type for testing exceptions.""" chosen_env = "m_adam_jip" - + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.2.8") + smile.cooling_present = False smile.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" smile.heater_id = "e4684553153b44afbef2200885f379dc" - smile.smile_version = "3.2.8" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.2.8") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "3.2.8" yield smile @pytest.fixture -def mock_smile_anna() -> Generator[MagicMock]: - """Create a Mock Anna environment for testing exceptions.""" - chosen_env = "anna_heatpump_heating" +def mock_smile_anna(chosen_env: str, cooling_present: bool) -> Generator[MagicMock]: + """Create a Mock Anna type for testing.""" + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("4.0.15") + smile.cooling_present = cooling_present smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_anna_2() -> Generator[MagicMock]: - """Create a 2nd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_cooling" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_anna_3() -> Generator[MagicMock]: - """Create a 3rd Mock Anna environment for testing exceptions.""" - chosen_env = "m_anna_heatpump_idle" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("4.0.15") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) yield smile @pytest.fixture -def mock_smile_p1() -> Generator[MagicMock]: - """Create a Mock P1 DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_single" +def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: + """Create a base Mock P1 type for testing with different datasets and gateway-ids.""" + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.gateway_id = "a455b61e52394b2db5081ce025a430f3" + smile.async_update.return_value = data + smile.connect.return_value = Version("4.4.2") + smile.gateway_id = gateway_id smile.heater_id = None - smile.smile_version = "4.4.2" - smile.smile_type = "power" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile" smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) - - yield smile - - -@pytest.fixture -def mock_smile_p1_2() -> Generator[MagicMock]: - """Create a Mock P1 3-phase DSMR environment for testing exceptions.""" - chosen_env = "p1v4_442_triple" - with patch( - "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value - - smile.gateway_id = "03e65b16e4b247a29ae0d75a78cb492e" - smile.heater_id = None - smile.smile_version = "4.4.2" smile.smile_type = "power" - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile" - smile.smile_name = "Smile P1" - smile.connect.return_value = Version("4.4.2") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_version = "4.4.2" yield smile @@ -320,24 +257,23 @@ def mock_smile_p1_2() -> Generator[MagicMock]: def mock_smile_legacy_anna() -> Generator[MagicMock]: """Create a Mock legacy Anna environment for testing exceptions.""" chosen_env = "legacy_anna" + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("1.8.22") smile.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" smile.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" - smile.smile_version = "1.8.22" - smile.smile_type = "thermostat" + smile.reboot = False smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = None smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("1.8.22") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "1.8.22" yield smile @@ -346,24 +282,23 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.1.11") smile.gateway_id = "259882df3c05415b99c2d962534ce820" smile.heater_id = None - smile.smile_version = "3.1.11" - smile.smile_type = "stretch" + smile.reboot = False smile.smile_hostname = "stretch98765" smile.smile_model = "Gateway" smile.smile_model_id = None smile.smile_name = "Stretch" - smile.connect.return_value = Version("3.1.11") - all_data = _read_json(chosen_env, "all_data") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "stretch" + smile.smile_version = "3.1.11" yield smile diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json new file mode 100644 index 00000000000..ab6bdf08e95 --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 20.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": false, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": true, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 35.0, + "modulation_level": 52, + "outdoor_air_temperature": 3.0, + "return_temperature": 25.1, + "water_pressure": 1.57, + "water_temperature": 29.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 21.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 19.3 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/legacy_anna/data.json b/tests/components/plugwise/fixtures/legacy_anna/data.json new file mode 100644 index 00000000000..cc7e66fb174 --- /dev/null +++ b/tests/components/plugwise/fixtures/legacy_anna/data.json @@ -0,0 +1,60 @@ +{ + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "1.8.22", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise" + }, + "04e4cbfe7f4340f090f85ec3b9e6a950": { + "binary_sensors": { + "flame_state": true, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "maximum_boiler_temperature": { + "lower_bound": 50.0, + "resolution": 1.0, + "setpoint": 50.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 51.2, + "intended_boiler_temperature": 17.0, + "modulation_level": 0.0, + "return_temperature": 21.7, + "water_pressure": 1.2, + "water_temperature": 23.6 + }, + "vendor": "Bosch Thermotechniek B.V." + }, + "0d266432d64443e283b5d708ae98b455": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "heating", + "dev_class": "thermostat", + "firmware": "2017-03-13T11:54:58+01:00", + "hardware": "6539-1301-500", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], + "sensors": { + "illuminance": 150.8, + "setpoint": 20.5, + "temperature": 20.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/data.json b/tests/components/plugwise/fixtures/m_adam_cooling/data.json new file mode 100644 index 00000000000..51f19ca3c03 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/data.json @@ -0,0 +1,203 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 17.5, + "water_temperature": 19.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "setpoint": 18.0, + "temperature": 21.6, + "temperature_difference": -0.2, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FF5EE" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "available": true, + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 23.5, + "temperature": 25.8 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "012345679891", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": [ + "bleeding_hot", + "bleeding_cold", + "off", + "heating", + "cooling" + ], + "select_gateway_mode": "full", + "select_regulation_mode": "cooling", + "sensors": { + "outdoor_temperature": 29.65 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5A168D" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": true + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 14, + "setpoint": 23.5, + "temperature": 23.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C869B61" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "cool", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 25.8 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 23.5, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 23.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 25.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_heating/data.json b/tests/components/plugwise/fixtures/m_adam_heating/data.json new file mode 100644 index 00000000000..b10ff8ec2a8 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/data.json @@ -0,0 +1,202 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 38.1, + "water_temperature": 37.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "setpoint": 18.0, + "temperature": 18.6, + "temperature_difference": -0.2, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FF5EE" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "available": true, + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 20.0, + "temperature": 19.1 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "012345679891", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], + "select_gateway_mode": "full", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": -1.25 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5A168D" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": true + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 14, + "setpoint": 15.0, + "temperature": 17.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C869B61" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "heat", + "control_state": "preheating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 17.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json new file mode 100644 index 00000000000..8de57910f66 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -0,0 +1,370 @@ +{ + "06aecb3d00354375924f50c47af36bd2": { + "active_preset": "no_frost", + "climate_mode": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Slaapkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 24.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], + "secondary": ["356b65335e274d769c338223e7af9c33"] + }, + "vendor": "Plugwise" + }, + "13228dab8ce04617af318a2888b3c548": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 27.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.01, + "setpoint": 9.0, + "upper_bound": 30.0 + }, + "thermostats": { + "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], + "secondary": ["833de10f269c4deab58fb9df69901b4e"] + }, + "vendor": "Plugwise" + }, + "1346fbd8498d4dbcab7e18d51b771f3d": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Lisa", + "model_id": "158-01", + "name": "Slaapkamer", + "sensors": { + "battery": 92, + "setpoint": 13.0, + "temperature": 24.2 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "1da4d325838e4ad8aac12177214505c9": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Logeerkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.8, + "temperature_difference": 2.0, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "356b65335e274d769c338223e7af9c33": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Slaapkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 24.2, + "temperature_difference": 1.7, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "457ce8414de24596a2d5e7dbc9c7682f": { + "available": true, + "dev_class": "zz_misc_plug", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "model": "Aqara Smart Plug", + "model_id": "lumi.plug.maeu01", + "name": "Plug", + "sensors": { + "electricity_consumed_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": false + }, + "vendor": "LUMI", + "zigbee_mac_address": "ABCD012345670A06" + }, + "6f3e9d7084214c21b9dfa46f6eeb8700": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Lisa", + "model_id": "158-01", + "name": "Kinderkamer", + "sensors": { + "battery": 79, + "setpoint": 13.0, + "temperature": 30.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "833de10f269c4deab58fb9df69901b4e": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Woonkamer", + "sensors": { + "setpoint": 9.0, + "temperature": 24.0, + "temperature_difference": 1.8, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "a6abc6a129ee499c88a4d420cc413b47": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Lisa", + "model_id": "158-01", + "name": "Logeerkamer", + "sensors": { + "battery": 80, + "setpoint": 13.0, + "temperature": 30.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "b5c2386c6f6342669e50fe49dd05b188": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.2.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "select_gateway_mode": "full", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 24.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "d27aede973b54be484f6842d1b2802ad": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Kinderkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], + "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] + }, + "vendor": "Plugwise" + }, + "d4496250d0e942cfa7aea3476e9070d5": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Kinderkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.7, + "temperature_difference": 1.9, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "d58fec52899f4f1c92e4f8fad6d8c48c": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Logeerkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["a6abc6a129ee499c88a4d420cc413b47"], + "secondary": ["1da4d325838e4ad8aac12177214505c9"] + }, + "vendor": "Plugwise" + }, + "e4684553153b44afbef2200885f379dc": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 20.0, + "resolution": 0.01, + "setpoint": 90.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "model_id": "10.20", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "modulation_level": 0.0, + "return_temperature": 37.1, + "water_pressure": 1.4, + "water_temperature": 37.3 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Remeha B.V." + }, + "f61f1a2535f54f52ad006a3d18e459ca": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermometer", + "firmware": "2020-09-01T02:00:00+02:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Jip", + "model_id": "168-01", + "name": "Woonkamer", + "sensors": { + "battery": 100, + "humidity": 56.2, + "setpoint": 9.0, + "temperature": 27.4 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json new file mode 100644 index 00000000000..7c38b1b2197 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -0,0 +1,584 @@ +{ + "02cf28bfec924855854c544690a609ef": { + "available": true, + "dev_class": "vcr_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "NVR", + "sensors": { + "electricity_consumed": 34.0, + "electricity_consumed_interval": 9.15, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A15" + }, + "08963fec7c53423ca5680aa4cb502c63": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": { + "temperature": 18.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": [ + "f1fee6043d3642a9b0a65297455f008e", + "680423ff840043738f42cc7f1ff97a36" + ], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "12493538af164a409c6a1c79e38afe1c": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 16.5 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["df4a4a8169904cdb9c03d61a21f42140"], + "secondary": ["a2c3583e0a6349358998b760cea82d2a"] + }, + "vendor": "Plugwise" + }, + "21f2b542c49845e6bb416884c55778d6": { + "available": true, + "dev_class": "game_console_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Playstation Smart Plug", + "sensors": { + "electricity_consumed": 84.1, + "electricity_consumed_interval": 8.6, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12" + }, + "446ac08dd04d4eff8ac57489757b7314": { + "active_preset": "no_frost", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 15.6 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["e7693eb9582644e5b865dba8d4447cf1"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "4a810418d5394b3f82727340b91ba740": { + "available": true, + "dev_class": "router_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "USG Smart Plug", + "sensors": { + "electricity_consumed": 8.5, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16" + }, + "675416a629f343c495449970e2ca37b5": { + "available": true, + "dev_class": "router_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Ziggo Modem", + "sensors": { + "electricity_consumed": 12.2, + "electricity_consumed_interval": 2.97, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "680423ff840043738f42cc7f1ff97a36": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Thermostatic Radiator Badkamer 1", + "sensors": { + "battery": 51, + "setpoint": 14.0, + "temperature": 19.1, + "temperature_difference": -0.4, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A17" + }, + "6a3bf693d05e48e0b460c815a4fdd09d": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "82fa13f017d240daa0d0ea1775420f24", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Thermostat Jessie", + "sensors": { + "battery": 37, + "setpoint": 15.0, + "temperature": 17.2 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "78d1126fc4c743db81b61c20e88342a7": { + "available": true, + "dev_class": "central_heating_pump_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Plug", + "model_id": "160-01", + "name": "CV Pomp", + "sensors": { + "electricity_consumed": 35.6, + "electricity_consumed_interval": 7.37, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "82fa13f017d240daa0d0ea1775420f24": { + "active_preset": "asleep", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": { + "temperature": 17.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], + "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] + }, + "vendor": "Plugwise" + }, + "90986d591dcd426cae3ec3e8111ff730": { + "binary_sensors": { + "heating_state": true + }, + "dev_class": "heater_central", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "model": "Unknown", + "name": "OnOff", + "sensors": { + "intended_boiler_temperature": 70.0, + "modulation_level": 1, + "water_temperature": 70.0 + } + }, + "a28f588dc4a049a483fd03a30361ad3a": { + "available": true, + "dev_class": "settop_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Fibaro HC2", + "sensors": { + "electricity_consumed": 12.5, + "electricity_consumed_interval": 3.8, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A13" + }, + "a2c3583e0a6349358998b760cea82d2a": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Bios Cv Thermostatic Radiator ", + "sensors": { + "battery": 62, + "setpoint": 13.0, + "temperature": 17.2, + "temperature_difference": -0.2, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "b310b72a0e354bfab43089919b9a88bf": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Floor kraan", + "sensors": { + "setpoint": 21.5, + "temperature": 26.0, + "temperature_difference": 3.5, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "b59bcebaf94b499ea7d46e4a66fb62d8": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-08-02T02:00:00+02:00", + "hardware": "255", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Lisa WK", + "sensors": { + "battery": 34, + "setpoint": 21.5, + "temperature": 20.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "c50f167537524366a5af7aa3942feb1e": { + "active_preset": "home", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": { + "electricity_consumed": 35.6, + "electricity_produced": 0.0, + "temperature": 20.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 21.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], + "secondary": ["b310b72a0e354bfab43089919b9a88bf"] + }, + "vendor": "Plugwise" + }, + "cd0ddb54ef694e11ac18ed1cbce5dbbd": { + "available": true, + "dev_class": "vcr_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "NAS", + "sensors": { + "electricity_consumed": 16.5, + "electricity_consumed_interval": 0.5, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14" + }, + "d3da73bde12a47d5a6b8f9dad971f2ec": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "82fa13f017d240daa0d0ea1775420f24", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Thermostatic Radiator Jessie", + "sensors": { + "battery": 62, + "setpoint": 15.0, + "temperature": 17.1, + "temperature_difference": 0.1, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10" + }, + "df4a4a8169904cdb9c03d61a21f42140": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Lisa Bios", + "sensors": { + "battery": 67, + "setpoint": 13.0, + "temperature": 16.5 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A06" + }, + "e7693eb9582644e5b865dba8d4447cf1": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "446ac08dd04d4eff8ac57489757b7314", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "CV Kraan Garage", + "sensors": { + "battery": 68, + "setpoint": 5.5, + "temperature": 15.6, + "temperature_difference": 0.0, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11" + }, + "f1fee6043d3642a9b0a65297455f008e": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Lisa", + "model_id": "158-01", + "name": "Thermostatic Radiator Badkamer 2", + "sensors": { + "battery": 92, + "setpoint": 14.0, + "temperature": 18.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + }, + "fe799307f1624099878210aa0b9f1475": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "3.0.15", + "hardware": "AME Smile 2.0 board", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": { + "af82e4ccf9c548528166d38e560662a4": { + "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." + } + }, + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 7.81 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + } +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json new file mode 100644 index 00000000000..ccfd816ff63 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 28.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": true, + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 41.5, + "intended_boiler_temperature": 0.0, + "modulation_level": 40, + "outdoor_air_temperature": 28.0, + "return_temperature": 23.8, + "water_pressure": 1.57, + "water_temperature": 22.7 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 21.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 26.3 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json new file mode 100644 index 00000000000..5a1cdebd380 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 28.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": false, + "cooling_enabled": true, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 18.0, + "modulation_level": 0, + "outdoor_air_temperature": 28.2, + "return_temperature": 22.0, + "water_pressure": 1.57, + "water_temperature": 19.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 25.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 23.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_single/data.json b/tests/components/plugwise/fixtures/p1v4_442_single/data.json new file mode 100644 index 00000000000..6dfcd7ee033 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_single/data.json @@ -0,0 +1,43 @@ +{ + "a455b61e52394b2db5081ce025a430f3": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.4.2", + "hardware": "AME Smile 2.0 board", + "location": "a455b61e52394b2db5081ce025a430f3", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile", + "name": "Smile P1", + "notifications": {}, + "vendor": "Plugwise" + }, + "ba4de7613517478da82dd9b6abea36af": { + "available": true, + "dev_class": "smartmeter", + "location": "a455b61e52394b2db5081ce025a430f3", + "model": "KFM5KAIFA-METER", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 17643.423, + "electricity_consumed_off_peak_interval": 15, + "electricity_consumed_off_peak_point": 486, + "electricity_consumed_peak_cumulative": 13966.608, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 486, + "electricity_phase_one_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 0.0, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "net_electricity_cumulative": 31610.031, + "net_electricity_point": 486 + }, + "vendor": "SHENZHEN KAIFA TECHNOLOGY \uff08CHENGDU\uff09 CO., LTD." + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/data.json new file mode 100644 index 00000000000..943325d1415 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/data.json @@ -0,0 +1,56 @@ +{ + "03e65b16e4b247a29ae0d75a78cb492e": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "4.4.2", + "hardware": "AME Smile 2.0 board", + "location": "03e65b16e4b247a29ae0d75a78cb492e", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile", + "name": "Smile P1", + "notifications": { + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } + }, + "vendor": "Plugwise" + }, + "b82b6b3322484f2ea4e25e0bd5f3d61f": { + "available": true, + "dev_class": "smartmeter", + "location": "03e65b16e4b247a29ae0d75a78cb492e", + "model": "XMX5LGF0010453051839", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 70537.898, + "electricity_consumed_off_peak_interval": 314, + "electricity_consumed_off_peak_point": 5553, + "electricity_consumed_peak_cumulative": 161328.641, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 1763, + "electricity_phase_one_produced": 0, + "electricity_phase_three_consumed": 2080, + "electricity_phase_three_produced": 0, + "electricity_phase_two_consumed": 1703, + "electricity_phase_two_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 0.0, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "gas_consumed_cumulative": 16811.37, + "gas_consumed_interval": 0.06, + "net_electricity_cumulative": 231866.539, + "net_electricity_point": 5553, + "voltage_phase_one": 233.2, + "voltage_phase_three": 234.7, + "voltage_phase_two": 234.4 + }, + "vendor": "XEMEX NV" + } +} diff --git a/tests/components/plugwise/fixtures/smile_p1_v2/data.json b/tests/components/plugwise/fixtures/smile_p1_v2/data.json new file mode 100644 index 00000000000..768dd2c2334 --- /dev/null +++ b/tests/components/plugwise/fixtures/smile_p1_v2/data.json @@ -0,0 +1,34 @@ +{ + "938696c4bcdb4b8a9a595cb38ed43913": { + "dev_class": "smartmeter", + "location": "938696c4bcdb4b8a9a595cb38ed43913", + "model": "Ene5\\T210-DESMR5.0", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 1642.74, + "electricity_consumed_off_peak_interval": 0, + "electricity_consumed_peak_cumulative": 1155.195, + "electricity_consumed_peak_interval": 250, + "electricity_consumed_point": 458, + "electricity_produced_off_peak_cumulative": 482.598, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_peak_cumulative": 1296.136, + "electricity_produced_peak_interval": 0, + "electricity_produced_point": 0, + "gas_consumed_cumulative": 584.433, + "gas_consumed_interval": 0.016, + "net_electricity_cumulative": 1019.201, + "net_electricity_point": 458 + }, + "vendor": "Ene5\\T210-DESMR5.0" + }, + "aaaa0000aaaa0000aaaa0000aaaa00aa": { + "dev_class": "gateway", + "firmware": "2.5.9", + "location": "938696c4bcdb4b8a9a595cb38ed43913", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Smile P1", + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/stretch_v31/data.json b/tests/components/plugwise/fixtures/stretch_v31/data.json new file mode 100644 index 00000000000..250839d08a8 --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/data.json @@ -0,0 +1,136 @@ +{ + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "3.1.11", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Stretch", + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "059e4d03c7a34d278add5c7a4a781d19": { + "dev_class": "washingmachine", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Wasmachine (52AC1)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "5871317346d045bc9f6b987ef25ee638": { + "dev_class": "water_heater_vessel", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4028", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Boiler (1EB31)", + "sensors": { + "electricity_consumed": 1.19, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "aac7b735042c4832ac9ff33aae4f453b": { + "dev_class": "dishwasher", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4022", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Vaatwasser (2a1ab)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.71, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "cfe95cf3de1948c0b8955125bf754614": { + "dev_class": "dryer", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Droger (52559)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "d03738edfcc947f7b8f4573571d90d2d": { + "dev_class": "switching", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "cfe95cf3de1948c0b8955125bf754614" + ], + "model": "Switchgroup", + "name": "Schakel", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "d950b314e9d8499f968e6db8d82ef78c": { + "dev_class": "report", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "5871317346d045bc9f6b987ef25ee638", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "e1c884e7dede431dadee09506ec4f859" + ], + "model": "Switchgroup", + "name": "Stroomvreters", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "e1c884e7dede431dadee09506ec4f859": { + "dev_class": "refrigerator", + "firmware": "2011-06-27T10:47:37+02:00", + "hardware": "6539-0700-7330", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle+ type F", + "name": "Koelkast (92C4A)", + "sensors": { + "electricity_consumed": 50.5, + "electricity_consumed_interval": 0.08, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "0123456789AB" + } +} diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 806c92fe7cb..92ed327b841 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -1,643 +1,633 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'devices': dict({ - '02cf28bfec924855854c544690a609ef': dict({ - 'available': True, - 'dev_class': 'vcr_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'NVR', - 'sensors': dict({ - 'electricity_consumed': 34.0, - 'electricity_consumed_interval': 9.15, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A15', + '02cf28bfec924855854c544690a609ef': dict({ + 'available': True, + 'dev_class': 'vcr_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'NVR', + 'sensors': dict({ + 'electricity_consumed': 34.0, + 'electricity_consumed_interval': 9.15, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, }), - '08963fec7c53423ca5680aa4cb502c63': dict({ - 'active_preset': 'away', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A15', + }), + '08963fec7c53423ca5680aa4cb502c63': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'temperature': 18.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'f1fee6043d3642a9b0a65297455f008e', + '680423ff840043738f42cc7f1ff97a36', ]), - 'climate_mode': 'auto', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Badkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'secondary': list([ ]), - 'select_schedule': 'Badkamer Schema', - 'sensors': dict({ - 'temperature': 18.9, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 14.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'f1fee6043d3642a9b0a65297455f008e', - '680423ff840043738f42cc7f1ff97a36', - ]), - 'secondary': list([ - ]), - }), - 'vendor': 'Plugwise', }), - '12493538af164a409c6a1c79e38afe1c': dict({ - 'active_preset': 'away', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'vendor': 'Plugwise', + }), + '12493538af164a409c6a1c79e38afe1c': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'off', + 'sensors': dict({ + 'electricity_consumed': 0.0, + 'electricity_produced': 0.0, + 'temperature': 16.5, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'df4a4a8169904cdb9c03d61a21f42140', ]), - 'climate_mode': 'heat', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Bios', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'secondary': list([ + 'a2c3583e0a6349358998b760cea82d2a', ]), - 'select_schedule': 'off', - 'sensors': dict({ - 'electricity_consumed': 0.0, - 'electricity_produced': 0.0, - 'temperature': 16.5, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 13.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'df4a4a8169904cdb9c03d61a21f42140', - ]), - 'secondary': list([ - 'a2c3583e0a6349358998b760cea82d2a', - ]), - }), - 'vendor': 'Plugwise', }), - '21f2b542c49845e6bb416884c55778d6': dict({ - 'available': True, - 'dev_class': 'game_console_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Playstation Smart Plug', - 'sensors': dict({ - 'electricity_consumed': 84.1, - 'electricity_consumed_interval': 8.6, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': False, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A12', + 'vendor': 'Plugwise', + }), + '21f2b542c49845e6bb416884c55778d6': dict({ + 'available': True, + 'dev_class': 'game_console_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Playstation Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 84.1, + 'electricity_consumed_interval': 8.6, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, }), - '446ac08dd04d4eff8ac57489757b7314': dict({ - 'active_preset': 'no_frost', - 'climate_mode': 'heat', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Garage', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'switches': dict({ + 'lock': False, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A12', + }), + '446ac08dd04d4eff8ac57489757b7314': dict({ + 'active_preset': 'no_frost', + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'sensors': dict({ + 'temperature': 15.6, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'e7693eb9582644e5b865dba8d4447cf1', ]), - 'sensors': dict({ - 'temperature': 15.6, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 5.5, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'e7693eb9582644e5b865dba8d4447cf1', - ]), - 'secondary': list([ - ]), - }), - 'vendor': 'Plugwise', - }), - '4a810418d5394b3f82727340b91ba740': dict({ - 'available': True, - 'dev_class': 'router_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'USG Smart Plug', - 'sensors': dict({ - 'electricity_consumed': 8.5, - 'electricity_consumed_interval': 0.0, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A16', - }), - '675416a629f343c495449970e2ca37b5': dict({ - 'available': True, - 'dev_class': 'router_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Ziggo Modem', - 'sensors': dict({ - 'electricity_consumed': 12.2, - 'electricity_consumed_interval': 2.97, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A01', - }), - '680423ff840043738f42cc7f1ff97a36': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '08963fec7c53423ca5680aa4cb502c63', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Thermostatic Radiator Badkamer 1', - 'sensors': dict({ - 'battery': 51, - 'setpoint': 14.0, - 'temperature': 19.1, - 'temperature_difference': -0.4, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A17', - }), - '6a3bf693d05e48e0b460c815a4fdd09d': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '82fa13f017d240daa0d0ea1775420f24', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Thermostat Jessie', - 'sensors': dict({ - 'battery': 37, - 'setpoint': 15.0, - 'temperature': 17.2, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A03', - }), - '78d1126fc4c743db81b61c20e88342a7': dict({ - 'available': True, - 'dev_class': 'central_heating_pump_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'CV Pomp', - 'sensors': dict({ - 'electricity_consumed': 35.6, - 'electricity_consumed_interval': 7.37, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A05', - }), - '82fa13f017d240daa0d0ea1775420f24': dict({ - 'active_preset': 'asleep', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'secondary': list([ ]), - 'climate_mode': 'auto', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Jessie', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + }), + 'vendor': 'Plugwise', + }), + '4a810418d5394b3f82727340b91ba740': dict({ + 'available': True, + 'dev_class': 'router_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'USG Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 8.5, + 'electricity_consumed_interval': 0.0, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A16', + }), + '675416a629f343c495449970e2ca37b5': dict({ + 'available': True, + 'dev_class': 'router_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Ziggo Modem', + 'sensors': dict({ + 'electricity_consumed': 12.2, + 'electricity_consumed_interval': 2.97, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A01', + }), + '680423ff840043738f42cc7f1ff97a36': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Thermostatic Radiator Badkamer 1', + 'sensors': dict({ + 'battery': 51, + 'setpoint': 14.0, + 'temperature': 19.1, + 'temperature_difference': -0.4, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A17', + }), + '6a3bf693d05e48e0b460c815a4fdd09d': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Thermostat Jessie', + 'sensors': dict({ + 'battery': 37, + 'setpoint': 15.0, + 'temperature': 17.2, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A03', + }), + '78d1126fc4c743db81b61c20e88342a7': dict({ + 'available': True, + 'dev_class': 'central_heating_pump_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'CV Pomp', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_consumed_interval': 7.37, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A05', + }), + '82fa13f017d240daa0d0ea1775420f24': dict({ + 'active_preset': 'asleep', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'temperature': 17.2, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + '6a3bf693d05e48e0b460c815a4fdd09d', ]), - 'select_schedule': 'CV Jessie', - 'sensors': dict({ - 'temperature': 17.2, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 15.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - '6a3bf693d05e48e0b460c815a4fdd09d', - ]), - 'secondary': list([ - 'd3da73bde12a47d5a6b8f9dad971f2ec', - ]), - }), - 'vendor': 'Plugwise', - }), - '90986d591dcd426cae3ec3e8111ff730': dict({ - 'binary_sensors': dict({ - 'heating_state': True, - }), - 'dev_class': 'heater_central', - 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', - 'model': 'Unknown', - 'name': 'OnOff', - 'sensors': dict({ - 'intended_boiler_temperature': 70.0, - 'modulation_level': 1, - 'water_temperature': 70.0, - }), - }), - 'a28f588dc4a049a483fd03a30361ad3a': dict({ - 'available': True, - 'dev_class': 'settop_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Fibaro HC2', - 'sensors': dict({ - 'electricity_consumed': 12.5, - 'electricity_consumed_interval': 3.8, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A13', - }), - 'a2c3583e0a6349358998b760cea82d2a': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '12493538af164a409c6a1c79e38afe1c', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Bios Cv Thermostatic Radiator ', - 'sensors': dict({ - 'battery': 62, - 'setpoint': 13.0, - 'temperature': 17.2, - 'temperature_difference': -0.2, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A09', - }), - 'b310b72a0e354bfab43089919b9a88bf': dict({ - 'available': True, - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Floor kraan', - 'sensors': dict({ - 'setpoint': 21.5, - 'temperature': 26.0, - 'temperature_difference': 3.5, - 'valve_position': 100, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A02', - }), - 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-08-02T02:00:00+02:00', - 'hardware': '255', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Lisa WK', - 'sensors': dict({ - 'battery': 34, - 'setpoint': 21.5, - 'temperature': 20.9, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A07', - }), - 'c50f167537524366a5af7aa3942feb1e': dict({ - 'active_preset': 'home', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'secondary': list([ + 'd3da73bde12a47d5a6b8f9dad971f2ec', ]), - 'climate_mode': 'auto', - 'control_state': 'heating', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Woonkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'GF7 Woonkamer', - 'sensors': dict({ - 'electricity_consumed': 35.6, - 'electricity_produced': 0.0, - 'temperature': 20.9, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 21.5, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'b59bcebaf94b499ea7d46e4a66fb62d8', - ]), - 'secondary': list([ - 'b310b72a0e354bfab43089919b9a88bf', - ]), - }), - 'vendor': 'Plugwise', }), - 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ - 'available': True, - 'dev_class': 'vcr_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'NAS', - 'sensors': dict({ - 'electricity_consumed': 16.5, - 'electricity_consumed_interval': 0.5, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A14', + 'vendor': 'Plugwise', + }), + '90986d591dcd426cae3ec3e8111ff730': dict({ + 'binary_sensors': dict({ + 'heating_state': True, }), - 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '82fa13f017d240daa0d0ea1775420f24', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Thermostatic Radiator Jessie', - 'sensors': dict({ - 'battery': 62, - 'setpoint': 15.0, - 'temperature': 17.1, - 'temperature_difference': 0.1, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A10', - }), - 'df4a4a8169904cdb9c03d61a21f42140': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '12493538af164a409c6a1c79e38afe1c', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Lisa Bios', - 'sensors': dict({ - 'battery': 67, - 'setpoint': 13.0, - 'temperature': 16.5, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A06', - }), - 'e7693eb9582644e5b865dba8d4447cf1': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '446ac08dd04d4eff8ac57489757b7314', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'CV Kraan Garage', - 'sensors': dict({ - 'battery': 68, - 'setpoint': 5.5, - 'temperature': 15.6, - 'temperature_difference': 0.0, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A11', - }), - 'f1fee6043d3642a9b0a65297455f008e': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '08963fec7c53423ca5680aa4cb502c63', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Thermostatic Radiator Badkamer 2', - 'sensors': dict({ - 'battery': 92, - 'setpoint': 14.0, - 'temperature': 18.9, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A08', - }), - 'fe799307f1624099878210aa0b9f1475': dict({ - 'binary_sensors': dict({ - 'plugwise_notification': True, - }), - 'dev_class': 'gateway', - 'firmware': '3.0.15', - 'hardware': 'AME Smile 2.0 board', - 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', - 'mac_address': '012345670001', - 'model': 'Gateway', - 'model_id': 'smile_open_therm', - 'name': 'Adam', - 'select_regulation_mode': 'heating', - 'sensors': dict({ - 'outdoor_temperature': 7.81, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670101', + 'dev_class': 'heater_central', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'model': 'Unknown', + 'name': 'OnOff', + 'sensors': dict({ + 'intended_boiler_temperature': 70.0, + 'modulation_level': 1, + 'water_temperature': 70.0, }), }), - 'gateway': dict({ - 'cooling_present': False, - 'gateway_id': 'fe799307f1624099878210aa0b9f1475', - 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 369, + 'a28f588dc4a049a483fd03a30361ad3a': dict({ + 'available': True, + 'dev_class': 'settop_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Fibaro HC2', + 'sensors': dict({ + 'electricity_consumed': 12.5, + 'electricity_consumed_interval': 3.8, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A13', + }), + 'a2c3583e0a6349358998b760cea82d2a': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Bios Cv Thermostatic Radiator ', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 13.0, + 'temperature': 17.2, + 'temperature_difference': -0.2, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A09', + }), + 'b310b72a0e354bfab43089919b9a88bf': dict({ + 'available': True, + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Floor kraan', + 'sensors': dict({ + 'setpoint': 21.5, + 'temperature': 26.0, + 'temperature_difference': 3.5, + 'valve_position': 100, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A02', + }), + 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-08-02T02:00:00+02:00', + 'hardware': '255', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Lisa WK', + 'sensors': dict({ + 'battery': 34, + 'setpoint': 21.5, + 'temperature': 20.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'c50f167537524366a5af7aa3942feb1e': dict({ + 'active_preset': 'home', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'heating', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Woonkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_produced': 0.0, + 'temperature': 20.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 21.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'b59bcebaf94b499ea7d46e4a66fb62d8', + ]), + 'secondary': list([ + 'b310b72a0e354bfab43089919b9a88bf', + ]), + }), + 'vendor': 'Plugwise', + }), + 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ + 'available': True, + 'dev_class': 'vcr_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'NAS', + 'sensors': dict({ + 'electricity_consumed': 16.5, + 'electricity_consumed_interval': 0.5, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A14', + }), + 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Thermostatic Radiator Jessie', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 15.0, + 'temperature': 17.1, + 'temperature_difference': 0.1, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A10', + }), + 'df4a4a8169904cdb9c03d61a21f42140': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Lisa Bios', + 'sensors': dict({ + 'battery': 67, + 'setpoint': 13.0, + 'temperature': 16.5, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A06', + }), + 'e7693eb9582644e5b865dba8d4447cf1': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '446ac08dd04d4eff8ac57489757b7314', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'CV Kraan Garage', + 'sensors': dict({ + 'battery': 68, + 'setpoint': 5.5, + 'temperature': 15.6, + 'temperature_difference': 0.0, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A11', + }), + 'f1fee6043d3642a9b0a65297455f008e': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Thermostatic Radiator Badkamer 2', + 'sensors': dict({ + 'battery': 92, + 'setpoint': 14.0, + 'temperature': 18.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A08', + }), + 'fe799307f1624099878210aa0b9f1475': dict({ + 'binary_sensors': dict({ + 'plugwise_notification': True, + }), + 'dev_class': 'gateway', + 'firmware': '3.0.15', + 'hardware': 'AME Smile 2.0 board', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'mac_address': '012345670001', + 'model': 'Gateway', + 'model_id': 'smile_open_therm', + 'name': 'Adam', 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", }), }), - 'reboot': True, - 'smile_name': 'Adam', + 'select_regulation_mode': 'heating', + 'sensors': dict({ + 'outdoor_temperature': 7.81, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670101', }), }) # --- diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 5c0e3fbdd2e..7bf475086af 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -9,32 +11,32 @@ from homeassistant.helpers.entity_component import async_update_entity from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize( + ("entity_id", "expected_state"), + [ + ("binary_sensor.opentherm_secondary_boiler_state", STATE_OFF), + ("binary_sensor.opentherm_dhw_state", STATE_OFF), + ("binary_sensor.opentherm_heating", STATE_ON), + ("binary_sensor.opentherm_cooling_enabled", STATE_OFF), + ("binary_sensor.opentherm_compressor_state", STATE_ON), + ], +) async def test_anna_climate_binary_sensor_entities( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, + entity_id: str, + expected_state: str, ) -> None: """Test creation of climate related binary_sensor entities.""" - - state = hass.states.get("binary_sensor.opentherm_secondary_boiler_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_dhw_state") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_heating") - assert state - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.opentherm_cooling_enabled") - assert state - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.opentherm_compressor_state") - assert state - assert state.state == STATE_ON + state = hass.states.get(entity_id) + assert state.state == expected_state +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -66,8 +68,12 @@ async def test_adam_climate_binary_sensor_change( assert not state.attributes.get("other_msg") -async def test_p1_v4_binary_sensor_entity( - hass: HomeAssistant, mock_smile_p1_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) +async def test_p1_binary_sensor_entity( + hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: """Test of a Smile P1 related plugwise-notification binary_sensor.""" state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 8368af8e5cc..7a481285be0 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -79,8 +79,12 @@ async def test_adam_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) async def test_adam_2_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of adam climate device environment.""" state = hass.states.get("climate.living_room") @@ -104,9 +108,11 @@ async def test_adam_2_climate_entity_attributes( ] +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_adam_3: MagicMock, + mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: @@ -120,19 +126,11 @@ async def test_adam_3_climate_entity_attributes( HVACMode.AUTO, HVACMode.COOL, ] - data = mock_smile_adam_3.async_update.return_value - data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( - "heating" - ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( - HVACAction.HEATING - ) - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "cooling_state" - ] = False - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "heating_state" - ] = True + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -148,19 +146,11 @@ async def test_adam_3_climate_entity_attributes( HVACMode.HEAT, ] - data = mock_smile_adam_3.async_update.return_value - data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( - "cooling" - ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( - HVACAction.COOLING - ) - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "cooling_state" - ] = True - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "heating_state" - ] = False + data = mock_smile_adam_heat_cool.async_update.return_value + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -266,7 +256,7 @@ async def test_adam_climate_entity_climate_changes( async def test_adam_climate_off_mode_change( hass: HomeAssistant, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in adam climate device environment.""" @@ -282,9 +272,9 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 1 - mock_smile_adam_4.set_regulation_mode.assert_called_with("heating") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 1 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("heating") state = hass.states.get("climate.kinderkamer") assert state @@ -298,9 +288,9 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 - mock_smile_adam_4.set_regulation_mode.assert_called_with("off") + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 + mock_smile_adam_jip.set_regulation_mode.assert_called_with("off") state = hass.states.get("climate.logeerkamer") assert state @@ -314,10 +304,12 @@ async def test_adam_climate_off_mode_change( }, blocking=True, ) - assert mock_smile_adam_4.set_schedule_state.call_count == 1 - assert mock_smile_adam_4.set_regulation_mode.call_count == 2 + assert mock_smile_adam_jip.set_schedule_state.call_count == 1 + assert mock_smile_adam_jip.set_regulation_mode.call_count == 2 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -343,9 +335,11 @@ async def test_anna_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_2_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_2: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" @@ -362,9 +356,11 @@ async def test_anna_2_climate_entity_attributes( assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5 +@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_3_climate_entity_attributes( hass: HomeAssistant, - mock_smile_anna_3: MagicMock, + mock_smile_anna: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of anna climate device environment.""" @@ -378,6 +374,8 @@ async def test_anna_3_climate_entity_attributes( ] +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -433,7 +431,7 @@ async def test_anna_climate_entity_climate_changes( ) data = mock_smile_anna.async_update.return_value - data.devices["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") + data["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 1f30fc972bb..16af7065c49 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -13,7 +13,6 @@ from plugwise.exceptions import ( import pytest from homeassistant.components.plugwise.const import DEFAULT_PORT, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -25,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 014003d29d0..5f1f065fa90 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -61,6 +61,8 @@ TOM = { } +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -80,6 +82,8 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("side_effect", "entry_state"), [ @@ -109,6 +113,10 @@ async def test_gateway_config_entry_not_ready( assert mock_config_entry.state is entry_state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_device_in_dr( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -131,6 +139,8 @@ async def test_device_in_dr( assert device_entry.sw_version == "4.4.2" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -224,16 +234,18 @@ async def test_migrate_unique_id_relay( assert entity_migrated.unique_id == new_unique_id +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_update_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smile_adam_2: MagicMock, + mock_smile_adam_heat_cool: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test a clean-up of the device_registry.""" - data = mock_smile_adam_2.async_update.return_value + data = mock_smile_adam_heat_cool.async_update.return_value mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -257,8 +269,8 @@ async def test_update_device( ) # Add a 2nd Tom/Floor - data.devices.update(TOM) - data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + data.update(TOM) + data["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( { "secondary": [ "01234567890abcdefghijklmnopqrstu", @@ -293,10 +305,10 @@ async def test_update_device( assert "01234567890abcdefghijklmnopqrstu" in item_list # Remove the existing Tom/Floor - data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + data["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( {"secondary": ["01234567890abcdefghijklmnopqrstu"]} ) - data.devices.pop("1772a4ea304041adb83f357b751341ff") + data.pop("1772a4ea304041adb83f357b751341ff") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index fdceb042669..4ae461d96c8 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -16,6 +16,8 @@ from homeassistant.exceptions import ServiceValidationError from tests.common import MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_number_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -25,6 +27,8 @@ async def test_anna_number_entities( assert float(state.state) == 60.0 +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_max_boiler_temp_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -45,19 +49,18 @@ async def test_anna_max_boiler_temp_change( ) -async def test_adam_number_entities( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: - """Test creation of a number.""" + """Test changing of number entities.""" state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") assert state assert float(state.state) == 60.0 - -async def test_adam_dhw_setpoint_change( - hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test changing of number entities.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -68,8 +71,8 @@ async def test_adam_dhw_setpoint_change( blocking=True, ) - assert mock_smile_adam_2.set_number.call_count == 1 - mock_smile_adam_2.set_number.assert_called_with( + assert mock_smile_adam_heat_cool.set_number.call_count == 1 + mock_smile_adam_heat_cool.set_number.assert_called_with( "056ee145a816487eaa69243c3280f8bf", "max_dhw_temperature", 55.0 ) diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index 8891a88bb91..f6c4205b756 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -50,8 +50,12 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_regulation_mode( - hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test a regulation_mode select. @@ -73,8 +77,8 @@ async def test_adam_select_regulation_mode( }, blocking=True, ) - assert mock_smile_adam_3.set_select.call_count == 1 - mock_smile_adam_3.set_select.assert_called_with( + assert mock_smile_adam_heat_cool.set_select.call_count == 1 + mock_smile_adam_heat_cool.set_select.assert_called_with( "select_regulation_mode", "bc93488efab249e5bc54fd7e175a6f91", "heating", @@ -91,6 +95,8 @@ async def test_legacy_anna_select_entities( assert not hass.states.get("select.anna_thermostat_schedule") +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_unavailable_regulation_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index f10f3f00933..c6c6c6cc284 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components.plugwise.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 homeassistant.helpers.entity_component import async_update_entity -import homeassistant.helpers.entity_registry as er from tests.common import MockConfigEntry @@ -41,7 +41,9 @@ async def test_adam_climate_sensor_entities( async def test_adam_climate_sensor_entity_2( - hass: HomeAssistant, mock_smile_adam_4: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of climate related sensor entities.""" state = hass.states.get("sensor.woonkamer_humidity") @@ -52,7 +54,7 @@ async def test_adam_climate_sensor_entity_2( async def test_unique_id_migration_humidity( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_adam_4: MagicMock, + mock_smile_adam_jip: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -relative_humidity to -humidity.""" @@ -92,6 +94,8 @@ async def test_unique_id_migration_humidity( assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-battery" +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -113,6 +117,10 @@ async def test_anna_as_smt_climate_sensor_entities( assert float(state.state) == 86.0 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["a455b61e52394b2db5081ce025a430f3"], indirect=True +) async def test_p1_dsmr_sensor_entities( hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -137,11 +145,15 @@ async def test_p1_dsmr_sensor_entities( assert not state +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_p1_3ph_dsmr_sensor_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test creation of power related sensor entities.""" @@ -163,10 +175,14 @@ async def test_p1_3ph_dsmr_sensor_entities( assert float(state.state) == 233.2 +@pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) +@pytest.mark.parametrize( + "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True +) async def test_p1_3ph_dsmr_sensor_disabled_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_smile_p1_2: MagicMock, + mock_smile_p1: MagicMock, init_integration: MockConfigEntry, ) -> None: """Test disabled power related sensor entities intent.""" diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index fa8a8a434e7..003c47ed1f4 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index bd1e3cfac29..ea003af86c7 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], diff --git a/tests/components/poolsense/snapshots/test_binary_sensor.ambr b/tests/components/poolsense/snapshots/test_binary_sensor.ambr index 8a6d39332d4..b3d99b95308 100644 --- a/tests/components/poolsense/snapshots/test_binary_sensor.ambr +++ b/tests/components/poolsense/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/poolsense/snapshots/test_sensor.ambr b/tests/components/poolsense/snapshots/test_sensor.ambr index 9029f1f24aa..c0066ba9396 100644 --- a/tests/components/poolsense/snapshots/test_sensor.ambr +++ b/tests/components/poolsense/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +203,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -247,6 +252,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -295,6 +301,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/powerfox/snapshots/test_sensor.ambr b/tests/components/powerfox/snapshots/test_sensor.ambr index a2aa8a9c72c..bae306ccabc 100644 --- a/tests/components/powerfox/snapshots/test_sensor.ambr +++ b/tests/components/powerfox/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -107,6 +109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -413,6 +421,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -464,6 +473,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -515,6 +525,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/powerfox/test_config_flow.py b/tests/components/powerfox/test_config_flow.py index a38f316faf3..377ae9c622c 100644 --- a/tests/components/powerfox/test_config_flow.py +++ b/tests/components/powerfox/test_config_flow.py @@ -5,18 +5,18 @@ from unittest.mock import AsyncMock, patch from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.powerfox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MOCK_DIRECT_HOST from tests.common import MockConfigEntry -MOCK_ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=MOCK_DIRECT_HOST, ip_addresses=[MOCK_DIRECT_HOST], hostname="powerfox.local", diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 1ff1470f81c..ab5034de637 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -11,13 +11,13 @@ from tesla_powerwall import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.powerwall.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_GATEWAY_DIN, @@ -161,7 +161,7 @@ async def test_already_configured(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -188,7 +188,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -230,7 +230,7 @@ async def test_dhcp_discovery_manual_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="any", @@ -272,7 +272,7 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -316,7 +316,7 @@ async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname="00GGX", @@ -394,7 +394,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -431,7 +431,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_fails( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -468,7 +468,7 @@ async def test_dhcp_discovery_does_not_update_ip_when_auth_successful( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -503,7 +503,7 @@ async def test_dhcp_discovery_updates_unique_id(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -542,7 +542,7 @@ async def test_dhcp_discovery_updates_unique_id_when_entry_is_failed( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -581,7 +581,7 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), @@ -630,7 +630,7 @@ async def test_discovered_wifi_does_not_update_ip_online_but_access_denied( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="aabbcceeddff", hostname=MOCK_GATEWAY_DIN.lower(), diff --git a/tests/components/powerwall/test_init.py b/tests/components/powerwall/test_init.py index e271cde0fc4..dd70dbb7c65 100644 --- a/tests/components/powerwall/test_init.py +++ b/tests/components/powerwall/test_init.py @@ -1,17 +1,23 @@ """Tests for the PowerwallDataManager.""" import datetime -from unittest.mock import patch +from http.cookies import Morsel +from unittest.mock import MagicMock, patch +from aiohttp import CookieJar from tesla_powerwall import AccessDeniedError, LoginResponse -from homeassistant.components.powerwall.const import DOMAIN +from homeassistant.components.powerwall.const import ( + AUTH_COOKIE_KEY, + CONFIG_ENTRY_COOKIE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .mocks import _mock_powerwall_with_fixtures +from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures from tests.common import MockConfigEntry, async_fire_time_changed @@ -37,7 +43,11 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) mock_powerwall.is_authenticated.return_value = True config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "password"} + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "password", + }, ) config_entry.add_to_hass(hass) with ( @@ -72,3 +82,226 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) assert len(flows) == 1 reauth_flow = flows[0] assert reauth_flow["context"]["source"] == "reauth" + + +async def test_init_uses_cookie_if_present(hass: HomeAssistant) -> None: + """Tests if the init will use the auth cookie if present. + + If the cookie is present, the login step will be skipped and info will be fetched directly (see _login_and_fetch_base_info). + """ + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not mock_powerwall.login.called + assert mock_powerwall.get_gateway_din.called + + +async def test_init_uses_password_if_no_cookies(hass: HomeAssistant) -> None: + """Tests if the init will use the password if no auth cookie present.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.called + + +async def test_init_saves_the_cookie(hass: HomeAssistant) -> None: + """Tests that the cookie is properly saved.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + mock_jar = MagicMock(CookieJar) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + patch("homeassistant.components.powerwall.CookieJar", return_value=mock_jar), + ): + auth_cookie = Morsel() + auth_cookie.set(AUTH_COOKIE_KEY, "somecookie", "somecookie") + mock_jar.__iter__.return_value = [auth_cookie] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONFIG_ENTRY_COOKIE] == "somecookie" + + +async def test_retry_ignores_cookie(hass: HomeAssistant) -> None: + """Tests that retrying uses the password instead.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert not mock_powerwall.login.called + assert mock_powerwall.get_gateway_din.called + + mock_powerwall.login.reset_mock() + mock_powerwall.get_charge.reset_mock() + + mock_powerwall.get_charge.side_effect = [AccessDeniedError("test"), 90.0] + + async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=1)) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.call_count == 2 + + +async def test_reauth_ignores_and_clears_cookie(hass: HomeAssistant) -> None: + """Tests that the reauth flow uses password and clears the cookie.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.reset_mock() + mock_powerwall.get_charge.reset_mock() + + mock_powerwall.get_charge.side_effect = [ + AccessDeniedError("test"), + AccessDeniedError("test"), + ] + + async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=1)) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_charge.call_count == 2 + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + reauth_flow = flows[0] + assert reauth_flow["context"]["source"] == "reauth" + + mock_powerwall.login.reset_mock() + assert config_entry.data[CONFIG_ENTRY_COOKIE] is not None + + await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], {CONF_PASSWORD: "somepassword"} + ) + + mock_powerwall.login.assert_called_with("somepassword") + assert config_entry.data[CONFIG_ENTRY_COOKIE] is None + + +async def test_init_retries_with_password(hass: HomeAssistant) -> None: + """Tests that the init retries with password if cookie fails.""" + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "somepassword", + CONFIG_ENTRY_COOKIE: "somecookie", + }, + ) + config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), + patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ), + ): + mock_powerwall.get_gateway_din.side_effect = [ + AccessDeniedError("get_gateway_din"), + MOCK_GATEWAY_DIN, + ] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_powerwall.login.assert_called_with("somepassword") + assert mock_powerwall.get_gateway_din.call_count == 2 diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index fa2d986d12a..9b533304fbc 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 540e644aca4..e724a9e5cab 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -34,7 +34,7 @@ from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 043a9cc4389..bbd58619b12 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -541,8 +541,7 @@ async def test_view_empty_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( @@ -569,8 +568,7 @@ async def test_view_default_namespace( assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + "# HELP python_gc_objects_collected_total Objects collected during gc" in body ) EntityMetric( diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 3d9673ffd90..f6cd4393511 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -5,19 +5,19 @@ 'entities': dict({ 'device_tracker.test1': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'is_in_ignored_zone': False, 'name': 'test1', }), 'device_tracker.test2': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test2', }), 'device_tracker.test3': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test3', }), @@ -42,7 +42,7 @@ }), 'proximity': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'nearest': 'test1', }), 'tracked_states': dict({ @@ -102,6 +102,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'home', 'unique_id': 'proximity_home', 'version': 1, diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index eeb181e0670..e9340014207 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import slugify from tests.common import MockConfigEntry @@ -129,7 +128,7 @@ async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -153,7 +152,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -170,7 +169,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -194,7 +193,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -211,7 +210,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "towards" @@ -273,7 +272,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -290,7 +289,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "stationary" @@ -361,7 +360,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -384,13 +383,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -433,7 +432,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -450,13 +449,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -490,7 +489,7 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -563,7 +562,7 @@ async def test_device_tracker_test1_awayfurther_test2_first( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -603,7 +602,7 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -626,13 +625,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "989156" + assert state.state == "989146" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -649,13 +648,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "1364567" + assert state.state == "1364557" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -694,15 +693,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "5176058" + assert state.state == "5176048" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "away_from" @@ -716,15 +715,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -738,15 +737,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -920,3 +919,95 @@ async def test_tracked_zone_is_removed(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNAVAILABLE + + +async def test_tracked_zone_radius_is_changed(hass: HomeAssistant) -> None: + """Test that radius of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.10000001, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change radius of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 110}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + radius = hass.states.get("zone.home").attributes["radius"] + assert radius == 110 + + # check sensor entities after radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218642" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_tracked_zone_location_is_changed(hass: HomeAssistant) -> None: + """Test that gps location of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change location of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 10, "longitude": 5, "radius": 10}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + latitude = hass.states.get("zone.home").attributes["latitude"] + assert latitude == 10 + longitude = hass.states.get("zone.home").attributes["longitude"] + assert longitude == 5 + + # check sensor entities after location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "1244478" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index 4e0505a8644..a4e6b039a92 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.util import location +from homeassistant.util import location as location_util from tests.common import MockConfigEntry @@ -64,7 +64,7 @@ MOCK_TCP_PORT = 997 MOCK_AUTO = {"Config Mode": "Auto Discover"} MOCK_MANUAL = {"Config Mode": "Manual Entry", CONF_IP_ADDRESS: MOCK_HOST} -MOCK_LOCATION = location.LocationInfo( +MOCK_LOCATION = location_util.LocationInfo( "0.0.0.0", "US", "USD", @@ -83,7 +83,8 @@ MOCK_LOCATION = location.LocationInfo( def location_info_fixture(): """Mock location info.""" with patch( - "homeassistant.components.ps4.config_flow.location.async_detect_location_info", + "homeassistant.components.ps4." + "config_flow.location_util.async_detect_location_info", return_value=MOCK_LOCATION, ): yield @@ -359,7 +360,8 @@ async def test_0_pin(hass: HomeAssistant) -> None: "pyps4_2ndscreen.Helper.has_devices", return_value=[{"host-ip": MOCK_HOST}] ), patch( - "homeassistant.components.ps4.config_flow.location.async_detect_location_info", + "homeassistant.components.ps4." + "config_flow.location_util.async_detect_location_info", return_value=MOCK_LOCATION, ), ): diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 12edb7a9c6e..4cde723a28f 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -31,7 +31,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import location +from homeassistant.util import location as location_util from tests.common import MockConfigEntry @@ -52,13 +52,14 @@ MOCK_FLOW_RESULT = { "title": "test_ps4", "data": MOCK_DATA, "options": {}, + "subentries": (), } MOCK_ENTRY_ID = "SomeID" MOCK_CONFIG = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA, entry_id=MOCK_ENTRY_ID) -MOCK_LOCATION = location.LocationInfo( +MOCK_LOCATION = location_util.LocationInfo( "0.0.0.0", "US", "USD", diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 4305dab2236..96704e900dd 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -5,12 +5,12 @@ from unittest.mock import MagicMock from gridnet import GridNetConnectionError -from homeassistant.components import zeroconf from homeassistant.components.pure_energie.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_full_user_flow_implementation( @@ -48,7 +48,7 @@ async def test_full_zeroconf_flow_implementationn( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -104,7 +104,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index ae4b28567be..6271a63d652 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -38,6 +38,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "fields": [ diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 46144771cc1..9b410a5fdd6 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -19,10 +20,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "test-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "test-username", CONF_VERIFY_SSL: False, } @@ -33,10 +32,8 @@ REAUTH_INPUT = { } NEW_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "new-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "new-username", CONF_VERIFY_SSL: False, } @@ -97,5 +94,28 @@ def mock_pyloadapi() -> Generator[MagicMock]: def mock_config_entry() -> MockConfigEntry: """Mock pyLoad configuration entry.""" return MockConfigEntry( - domain=DOMAIN, title=DEFAULT_NAME, data=USER_INPUT, entry_id="XXXXXXXXXXXXXX" + domain=DOMAIN, + title=DEFAULT_NAME, + data=USER_INPUT, + entry_id="XXXXXXXXXXXXXX", + ) + + +@pytest.fixture(name="config_entry_migrate") +def mock_config_entry_migrate() -> MockConfigEntry: + """Mock pyLoad configuration entry for migration.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={ + CONF_HOST: "pyload.local", + CONF_PASSWORD: "test-password", + CONF_PORT: 8000, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_VERIFY_SSL: False, + }, + version=1, + minor_version=0, + entry_id="XXXXXXXXXXXXXX", ) diff --git a/tests/components/pyload/snapshots/test_button.ambr b/tests/components/pyload/snapshots/test_button.ambr index bf1e1f59c98..57a0358da42 100644 --- a/tests/components/pyload/snapshots/test_button.ambr +++ b/tests/components/pyload/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index e2b51ad184a..81a5d750bc0 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -2,10 +2,8 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ - 'host': '**REDACTED**', 'password': '**REDACTED**', - 'port': 8000, - 'ssl': True, + 'url': 'https://**redacted**:8000/prefix', 'username': '**REDACTED**', 'verify_ssl': False, }), diff --git a/tests/components/pyload/snapshots/test_sensor.ambr b/tests/components/pyload/snapshots/test_sensor.ambr index 69d0387fc8f..d9948f4273a 100644 --- a/tests/components/pyload/snapshots/test_sensor.ambr +++ b/tests/components/pyload/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -216,6 +220,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +271,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,7 +310,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '1', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_downloads_in_queue-entry] @@ -316,6 +322,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -354,7 +361,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '6', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_free_space-entry] @@ -364,6 +371,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -408,7 +416,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '93.1322574606165', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_speed-entry] @@ -418,6 +426,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -462,7 +471,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '43.247704', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_total_downloads-entry] @@ -474,6 +483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,7 +522,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '37', }) # --- # name: test_sensor_update_exceptions[ParserError][sensor.pyload_active_downloads-entry] @@ -524,6 +534,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -622,6 +634,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +689,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -732,6 +746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -782,6 +797,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -832,6 +848,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -880,6 +897,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -934,6 +952,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -990,6 +1009,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/pyload/snapshots/test_switch.ambr b/tests/components/pyload/snapshots/test_switch.ambr index 0fcc45f8586..479013b09e4 100644 --- a/tests/components/pyload/snapshots/test_switch.ambr +++ b/tests/components/pyload/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index 12713ef2e54..5c85979b9df 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -1,14 +1,17 @@ """Test pyLoad init.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_PATH, CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_entry_setup_unload( @@ -63,3 +66,45 @@ async def test_config_entry_setup_invalid_auth( assert config_entry.state is ConfigEntryState.SETUP_ERROR assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +async def test_coordinator_update_invalid_auth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pyloadapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator authentication.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pyloadapi.login.side_effect = InvalidAuth + mock_pyloadapi.get_status.side_effect = InvalidAuth + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_migration( + hass: HomeAssistant, + config_entry_migrate: MockConfigEntry, +) -> None: + """Test config entry migration.""" + + config_entry_migrate.add_to_hass(hass) + assert config_entry_migrate.data.get(CONF_PATH) is None + + await hass.config_entries.async_setup(config_entry_migrate.entry_id) + await hass.async_block_till_done() + + assert config_entry_migrate.state is ConfigEntryState.LOADED + assert config_entry_migrate.version == 1 + assert config_entry_migrate.minor_version == 1 + assert config_entry_migrate.data[CONF_URL] == "https://pyload.local:8000/" diff --git a/tests/components/qbus/__init__.py b/tests/components/qbus/__init__.py new file mode 100644 index 00000000000..e8c002d1ed9 --- /dev/null +++ b/tests/components/qbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the Qbus integration.""" diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py new file mode 100644 index 00000000000..8268d091bda --- /dev/null +++ b/tests/components/qbus/conftest.py @@ -0,0 +1,33 @@ +"""Test fixtures for qbus.""" + +import pytest + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import FIXTURE_PAYLOAD_CONFIG + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="000001", + data={ + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def payload_config() -> JsonObjectType: + """Return the config topic payload.""" + return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) diff --git a/tests/components/qbus/const.py b/tests/components/qbus/const.py new file mode 100644 index 00000000000..408ef59d5b1 --- /dev/null +++ b/tests/components/qbus/const.py @@ -0,0 +1,4 @@ +"""Define const for unit tests.""" + +FIXTURE_PAYLOAD_CONFIG = "payload_config.json" +TOPIC_CONFIG = "cloudapp/QBUSMQTTGW/config" diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json new file mode 100644 index 00000000000..e2c7f463e4e --- /dev/null +++ b/tests/components/qbus/fixtures/payload_config.json @@ -0,0 +1,72 @@ +{ + "app": "abc", + "devices": [ + { + "id": "UL1", + "ip": "192.168.1.123", + "mac": "001122334455", + "name": "", + "serialNr": "000001", + "type": "Qbus", + "version": "3.14.0", + "properties": { + "connectable": { + "read": true, + "type": "boolean", + "write": false + }, + "connected": { + "read": true, + "type": "boolean", + "write": false + } + }, + "functionBlocks": [ + { + "id": "UL10", + "location": "Living", + "locationId": 0, + "name": "LIVING", + "originalName": "LIVING", + "refId": "000001/10", + "type": "onoff", + "variant": [null], + "actions": { + "off": null, + "on": null + }, + "properties": { + "value": { + "read": true, + "type": "boolean", + "write": true + } + } + }, + { + "id": "UL15", + "location": "Media room", + "locationId": 0, + "name": "MEDIA ROOM", + "originalName": "MEDIA ROOM", + "refId": "000001/28", + "type": "analog", + "actions": { + "off": null, + "on": null + }, + "properties": { + "value": { + "max": 100, + "min": 5, + "read": true, + "step": 0.1, + "type": "number", + "write": true + } + } + } + ] + } + ] +} diff --git a/tests/components/qbus/test_config_flow.py b/tests/components/qbus/test_config_flow.py new file mode 100644 index 00000000000..4f94f2bb277 --- /dev/null +++ b/tests/components/qbus/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test config flow.""" + +import json +import time +from unittest.mock import patch + +import pytest +from qbusmqttapi.discovery import QbusDiscovery + +from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.qbus.coordinator import QbusConfigCoordinator +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +_PAYLOAD_DEVICE_STATE = '{"id":"UL1","properties":{"connected":true},"type":"event"}' + + +async def test_step_discovery_confirm_create_entry( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test mqtt confirm step and entry creation.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_ID: "UL1", + CONF_SERIAL_NUMBER: "000001", + } + assert result.get("result").unique_id == "000001" + + +@pytest.mark.parametrize( + ("topic", "payload"), + [ + ("cloudapp/QBUSMQTTGW/state", b""), + ("invalid/topic", b"{}"), + ], +) +async def test_step_mqtt_invalid( + hass: HomeAssistant, topic: str, payload: bytes +) -> None: + """Test mqtt discovery with empty payload.""" + discovery = MqttServiceInfo( + subscribed_topic=topic, + topic=topic, + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +@pytest.mark.parametrize( + ("payload", "mqtt_publish"), + [ + ('{ "online": true }', True), + ('{ "online": false }', False), + ], +) +async def test_handle_gateway_topic_when_online( + hass: HomeAssistant, payload: str, mqtt_publish: bool +) -> None: + """Test handling of gateway topic with payload indicating online.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/state", + topic="cloudapp/QBUSMQTTGW/state", + payload=payload, + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called is mqtt_publish + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_config_topic( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of config topic.""" + + discovery = MqttServiceInfo( + subscribed_topic=TOPIC_CONFIG, + topic=TOPIC_CONFIG, + payload=json.dumps(payload_config), + qos=0, + retain=False, + timestamp=time.time(), + ) + + with ( + patch("homeassistant.components.mqtt.client.async_publish") as mock_publish, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert mock_publish.called + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "discovery_in_progress" + + +async def test_handle_device_topic_missing_config(hass: HomeAssistant) -> None: + """Test handling of device topic when config is missing.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL1/state", + payload=_PAYLOAD_DEVICE_STATE, + qos=0, + retain=False, + timestamp=time.time(), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_handle_device_topic_device_not_found( + hass: HomeAssistant, payload_config: JsonObjectType +) -> None: + """Test handling of device topic when device is not found.""" + discovery = MqttServiceInfo( + subscribed_topic="cloudapp/QBUSMQTTGW/+/state", + topic="cloudapp/QBUSMQTTGW/UL2/state", + payload='{"id":"UL2","properties":{"connected":true},"type":"event"}', + qos=0, + retain=False, + timestamp=time.time(), + ) + + with patch.object( + QbusConfigCoordinator, + "async_get_or_request_config", + return_value=QbusDiscovery(payload_config), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_step_user_not_supported(hass: HomeAssistant) -> None: + """Test user step, which should abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "not_supported" diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py new file mode 100644 index 00000000000..c64219f1269 --- /dev/null +++ b/tests/components/qbus/test_light.py @@ -0,0 +1,118 @@ +"""Test Qbus light entities.""" + +import json + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +# 186 = 73% (rounded) +_BRIGHTNESS = 186 +_BRIGHTNESS_PCT = 73 + +_PAYLOAD_LIGHT_STATE_ON = '{"id":"UL15","properties":{"value":60},"type":"state"}' +_PAYLOAD_LIGHT_STATE_BRIGHTNESS = ( + '{"id":"UL15","properties":{"value":' + str(_BRIGHTNESS_PCT) + '},"type":"state"}' +) +_PAYLOAD_LIGHT_STATE_OFF = '{"id":"UL15","properties":{"value":0},"type":"state"}' + +_PAYLOAD_LIGHT_SET_STATE_ON = '{"id": "UL15", "type": "action", "action": "on"}' +_PAYLOAD_LIGHT_SET_STATE_BRIGHTNESS = ( + '{"id": "UL15", "type": "state", "properties": {"value": ' + + str(_BRIGHTNESS_PCT) + + "}}" +) +_PAYLOAD_LIGHT_SET_STATE_OFF = '{"id": "UL15", "type": "action", "action": "off"}' + +_TOPIC_LIGHT_STATE = "cloudapp/QBUSMQTTGW/UL1/UL15/state" +_TOPIC_LIGHT_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL15/setState" + +_LIGHT_ENTITY_ID = "light.media_room" + + +async def test_light( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Test turning on and off.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _LIGHT_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_ON, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_ON + + # Set brightness + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: _LIGHT_ENTITY_ID, + ATTR_BRIGHTNESS: _BRIGHTNESS, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_BRIGHTNESS, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_BRIGHTNESS) + await hass.async_block_till_done() + + entity = hass.states.get(_LIGHT_ENTITY_ID) + assert entity.state == STATE_ON + assert entity.attributes.get(ATTR_BRIGHTNESS) == _BRIGHTNESS + + # Switch OFF + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: _LIGHT_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_OFF, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py new file mode 100644 index 00000000000..83bb667e4eb --- /dev/null +++ b/tests/components/qbus/test_switch.py @@ -0,0 +1,84 @@ +"""Test Qbus switch entities.""" + +import json + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +_PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' +_PAYLOAD_SWITCH_STATE_OFF = '{"id":"UL10","properties":{"value":false},"type":"state"}' +_PAYLOAD_SWITCH_SET_STATE_ON = ( + '{"id": "UL10", "type": "state", "properties": {"value": true}}' +) +_PAYLOAD_SWITCH_SET_STATE_OFF = ( + '{"id": "UL10", "type": "state", "properties": {"value": false}}' +) + +_TOPIC_SWITCH_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/state" +_TOPIC_SWITCH_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL10/setState" + +_SWITCH_ENTITY_ID = "switch.living" + + +async def test_switch_turn_on_off( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Test turning on and off.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_ON, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_ON + + # Switch OFF + mqtt_mock.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: _SWITCH_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_SWITCH_SET_STATE, _PAYLOAD_SWITCH_SET_STATE_OFF, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_SWITCH_STATE, _PAYLOAD_SWITCH_STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(_SWITCH_ENTITY_ID).state == STATE_OFF diff --git a/tests/components/qingping/test_config_flow.py b/tests/components/qingping/test_config_flow.py index 7bcd9c09e68..9d3d2a49e26 100644 --- a/tests/components/qingping/test_config_flow.py +++ b/tests/components/qingping/test_config_flow.py @@ -114,6 +114,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LIGHT_AND_SIGNAL_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.qingping.config_flow.async_discovered_service_info", + return_value=[LIGHT_AND_SIGNAL_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.qingping.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Motion & Light EEFF" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 20659182726..aefee4113cc 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 94e80d3cd16..f09cf7493b5 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -6,19 +6,19 @@ from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT from aioqsw.exceptions import LoginError, QswError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry -DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( +DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="qsw-m408-4c", ip="192.168.1.200", macaddress="245ebe000000", diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py index 7f9479339a5..db4f4de6c49 100644 --- a/tests/components/rabbitair/test_config_flow.py +++ b/tests/components/rabbitair/test_config_flow.py @@ -10,12 +10,12 @@ import pytest from rabbitair import Mode, Model, Speed from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rabbitair.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo TEST_HOST = "1.1.1.1" TEST_NAME = "abcdef1234_123456789012345678" @@ -26,7 +26,7 @@ TEST_HARDWARE = "1.0.0.4" TEST_UNIQUE_ID = format_mac(TEST_MAC) TEST_TITLE = "Rabbit Air" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], port=9009, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 586b31b092f..6448d46a8a1 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -4,7 +4,6 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.rachio.const import ( CONF_CUSTOM_URL, CONF_MANUAL_RUN_MINS, @@ -13,6 +12,10 @@ from homeassistant.components.rachio.const import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry @@ -120,13 +123,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -145,13 +148,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -171,13 +174,13 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 563ac504057..f6b14bffa80 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import setup_integration @@ -68,13 +68,13 @@ async def test_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" state = hass.states.get("sensor.mock_title_movies") assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP state = hass.states.get("sensor.mock_title_queue") assert state.state == "2" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index a188f8fcb70..a84f3870357 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -6,11 +6,11 @@ from radiotherm import CommonThermostat from radiotherm.validate import RadiothermTstatError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.radiotherm.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -156,7 +156,7 @@ async def test_dhcp_fails_to_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -185,7 +185,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="radiotherm", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py index 7e75440c30d..320299d2e60 100644 --- a/tests/components/rainforest_raven/const.py +++ b/tests/components/rainforest_raven/const.py @@ -13,8 +13,9 @@ from aioraven.data import ( from iso4217 import Currency from homeassistant.components import usb +from homeassistant.helpers.service_info.usb import UsbServiceInfo -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device="/dev/ttyACM0", pid="0x0003", vid="0x04B4", diff --git a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr index e131bf3d952..abf8e380916 100644 --- a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr +++ b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -84,6 +86,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 768bbc729d4..8a143f9963f 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -8,6 +8,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/rainforest_raven/snapshots/test_sensor.ambr b/tests/components/rainforest_raven/snapshots/test_sensor.ambr index 34a5e031885..618766c1613 100644 --- a/tests/components/rainforest_raven/snapshots/test_sensor.ambr +++ b/tests/components/rainforest_raven/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +217,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr index 9c930736fe3..c4d6f2eeae1 100644 --- a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr +++ b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_button.ambr b/tests/components/rainmachine/snapshots/test_button.ambr index 609079bb0d8..68f83d9286a 100644 --- a/tests/components/rainmachine/snapshots/test_button.ambr +++ b/tests/components/rainmachine/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr index acd5fd165b4..681805996f1 100644 --- a/tests/components/rainmachine/snapshots/test_diagnostics.ambr +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -1144,6 +1144,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, @@ -2275,6 +2277,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/rainmachine/snapshots/test_select.ambr b/tests/components/rainmachine/snapshots/test_select.ambr index 651a709d2fa..d150f8c31b5 100644 --- a/tests/components/rainmachine/snapshots/test_select.ambr +++ b/tests/components/rainmachine/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_sensor.ambr b/tests/components/rainmachine/snapshots/test_sensor.ambr index e93d0645030..2475abecb51 100644 --- a/tests/components/rainmachine/snapshots/test_sensor.ambr +++ b/tests/components/rainmachine/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -242,6 +247,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -289,6 +295,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -336,6 +343,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -383,6 +391,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,6 +439,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +487,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -524,6 +535,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -571,6 +583,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -618,6 +631,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +679,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_switch.ambr b/tests/components/rainmachine/snapshots/test_switch.ambr index b803ff994d4..d40913a7eb0 100644 --- a/tests/components/rainmachine/snapshots/test_switch.ambr +++ b/tests/components/rainmachine/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +79,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,6 +238,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -329,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -510,6 +519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +568,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -619,6 +630,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -667,6 +679,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -728,6 +741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -776,6 +790,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -837,6 +852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -885,6 +901,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -946,6 +963,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -994,6 +1012,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1055,6 +1074,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1123,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1164,6 +1185,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1212,6 +1234,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1273,6 +1296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1321,6 +1345,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1382,6 +1407,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1430,6 +1456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1491,6 +1518,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1539,6 +1567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1600,6 +1629,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5838dcc35c8..cd8b2cb39c8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -7,7 +7,6 @@ import pytest from regenmaschine.errors import RainMachineError from homeassistant import config_entries, setup -from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, @@ -18,6 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo async def test_duplicate_error(hass: HomeAssistant, config, config_entry) -> None: @@ -168,7 +168,7 @@ async def test_step_homekit_zeroconf_ip_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -196,7 +196,7 @@ async def test_step_homekit_zeroconf_ip_change( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.2"), ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", @@ -225,7 +225,7 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -279,7 +279,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", @@ -299,7 +299,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.100"), ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index 24c690bcb37..a57e289ec04 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -34,6 +34,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": [ { diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index cae181a6270..91f5bd50298 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -58,7 +58,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_event_data( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -91,7 +91,7 @@ async def test_validate_db_schema_fix_utf8_issue_event_data( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 915ac1f3500..982a6a732b6 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -60,7 +60,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_states( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -92,7 +92,7 @@ async def test_validate_db_schema_fix_utf8_issue_states( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_state_attributes( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -125,7 +125,7 @@ async def test_validate_db_schema_fix_utf8_issue_state_attributes( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 9e287d13594..2466a761364 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -17,17 +17,17 @@ from homeassistant.components.recorder.auto_repairs.statistics.duplicates import from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ...common import async_wait_recording_done from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -134,7 +134,7 @@ def _create_engine_28(*args, **kwargs): @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_delete_metadata_duplicates( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics.""" @@ -242,7 +242,7 @@ async def test_delete_metadata_duplicates( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_delete_metadata_duplicates_many( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics.""" diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 34a075afbc7..352a2345052 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -8,12 +8,12 @@ from homeassistant.core import HomeAssistant from ...common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -22,7 +22,7 @@ async def mock_recorder_before_hass( @pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, db_engine: str, recorder_dialect_name: None, @@ -56,7 +56,7 @@ async def test_validate_db_schema_fix_utf8_issue( @pytest.mark.parametrize("db_engine", ["mysql", "postgresql"]) async def test_validate_db_schema_fix_float_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, table: str, db_engine: str, @@ -100,7 +100,7 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( hass: HomeAssistant, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_dialect_name: None, db_engine: str, diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index 857c0f6572f..bf2a925df17 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -18,12 +18,12 @@ from homeassistant.core import HomeAssistant from ..common import async_wait_recording_done -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index fbb0991c960..28eb097f576 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -15,7 +15,7 @@ from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session from homeassistant import core as ha @@ -37,7 +37,8 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.helpers import recorder as recorder_helper +from homeassistant.util import dt as dt_util from . import db_schema_0 @@ -79,6 +80,11 @@ async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None: await event.wait() +async def async_wait_recorder(hass: HomeAssistant) -> bool: + """Wait for recorder to initialize and return connection status.""" + return await hass.data[recorder_helper.DATA_RECORDER].db_connected + + def get_start_time(start: datetime) -> datetime: """Calculate a valid start time for statistics.""" start_minutes = start.minute - start.minute % 5 @@ -408,7 +414,15 @@ def create_engine_test_for_schema_version_postfix( schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) old_db_schema = sys.modules[schema_module] + instance: Recorder | None = None + if "hass" in kwargs: + hass: HomeAssistant = kwargs.pop("hass") + instance = recorder.get_instance(hass) engine = create_engine(*args, **kwargs) + if instance is not None: + instance = recorder.get_instance(hass) + instance.engine = engine + sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: session.add( @@ -429,7 +443,7 @@ def get_schema_module_path(schema_version_postfix: str) -> str: @contextmanager -def old_db_schema(schema_version_postfix: str) -> Iterator[None]: +def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) @@ -449,6 +463,7 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, + hass=hass, schema_version_postfix=schema_version_postfix, ), ), diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 9cdf9dbb372..681205126af 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import db_schema +from homeassistant.components.recorder.const import MAX_IDS_FOR_INDEXED_GROUP_BY from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -190,3 +191,9 @@ def instrument_migration( instrumented_migration.live_migration_done_stall.set() instrumented_migration.non_live_migration_done_stall.set() yield instrumented_migration + + +@pytest.fixture(params=[1, 2, MAX_IDS_FOR_INDEXED_GROUP_BY]) +def ids_for_start_time_chunk_sizes(request: pytest.FixtureRequest) -> int: + """Fixture to test different chunk sizes for start time query.""" + return request.param diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 12336dcc96a..12228e99211 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -23,7 +23,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index d7ca35c9341..3455af1d019 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -347,11 +347,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index adb71dffb9e..9e9dc786580 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -360,11 +360,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index c0d607b12a7..766ff88ff72 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -479,11 +479,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index f60b7b49df4..fe36029b61f 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -469,11 +469,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 4cc1074de41..a77bc1fcbd5 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -49,7 +49,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -593,11 +593,11 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", + "_row", ] def __init__(self, row) -> None: # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index d989cacb76a..bd3cb23bd07 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -529,12 +529,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 8c984b61f6c..7f34343d995 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -43,7 +43,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() @@ -694,12 +694,12 @@ class LazyState(State): """A lazy version of core State.""" __slots__ = [ - "_row", + "_attr_cache", "_attributes", + "_context", "_last_changed", "_last_updated", - "_context", - "_attr_cache", + "_row", ] def __init__( # pylint: disable=super-init-not-called diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 97c33334111..185dce786de 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -50,7 +50,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 39ddb8e3148..daa7fb6977c 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -51,7 +51,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_42.py index efeade46562..a5381d633cb 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_42.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_43.py b/tests/components/recorder/db_schema_43.py index 8e77e8782ee..379e6fbd416 100644 --- a/tests/components/recorder/db_schema_43.py +++ b/tests/components/recorder/db_schema_43.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index f9a8c2d2cad..6cf7085e279 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -19,13 +19,12 @@ from sqlalchemy import ( Text, distinct, ) -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index 08fbef01bdd..a4362b1fa4c 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -1,12 +1,13 @@ """Test backup platform for the Recorder integration.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,7 +17,42 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> "homeassistant.components.recorder.core.Recorder.lock_database" ) as lock_mock: await async_pre_backup(hass) - assert lock_mock.called + assert lock_mock.called + + +RAISES_HASS_NOT_RUNNING = pytest.raises( + HomeAssistantError, match="Home Assistant is not running" +) + + +@pytest.mark.parametrize( + ("core_state", "expected_result", "lock_calls"), + [ + (CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.running, does_not_raise(), 1), + (CoreState.starting, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), + ], +) +async def test_async_pre_backup_core_state( + recorder_mock: Recorder, + hass: HomeAssistant, + core_state: CoreState, + expected_result: AbstractContextManager, + lock_calls: int, +) -> None: + """Test pre backup in different core states.""" + hass.set_state(core_state) + with ( # pylint: disable=confusing-with-statement + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, + expected_result, + ): + await async_pre_backup(hass) + assert len(lock_mock.mock_calls) == lock_calls async def test_async_pre_backup_with_timeout( @@ -39,13 +75,17 @@ async def test_async_pre_backup_with_migration( ) -> None: """Test pre backup with migration.""" with ( + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, patch( "homeassistant.components.recorder.backup.async_migration_in_progress", return_value=True, ), - pytest.raises(HomeAssistantError), + pytest.raises(HomeAssistantError, match="Database migration in progress"), ): await async_pre_backup(hass) + assert not lock_mock.called async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -54,7 +94,7 @@ async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) - "homeassistant.components.recorder.core.Recorder.unlock_database" ) as unlock_mock: await async_post_backup(hass) - assert unlock_mock.called + assert unlock_mock.called async def test_async_post_backup_failure( @@ -66,7 +106,9 @@ async def test_async_post_backup_failure( "homeassistant.components.recorder.core.Recorder.unlock_database", return_value=False, ) as unlock_mock, - pytest.raises(HomeAssistantError), + pytest.raises( + HomeAssistantError, match="Could not release database write lock" + ), ): await async_post_backup(hass) assert unlock_mock.called diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py index ad438dcc525..8a5ce23799c 100644 --- a/tests/components/recorder/test_entity_registry.py +++ b/tests/components/recorder/test_entity_registry.py @@ -23,7 +23,7 @@ from .common import ( ) from tests.common import MockEntity, MockEntityPlatform -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager def _count_entity_id_in_states_meta( @@ -40,7 +40,7 @@ def _count_entity_id_in_states_meta( @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index d3024df4ed6..2e9883aaf53 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,6 +1,6 @@ """The tests for the recorder filter matching the EntityFilter component.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator import json from unittest.mock import patch @@ -32,12 +32,21 @@ from homeassistant.helpers.entityfilter import ( from .common import async_wait_recording_done, old_db_schema +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + # This test is for schema 37 and below (32 is new enough to test) @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 28b8275247c..d6223eb55b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import sentinel +from unittest.mock import patch, sentinel from freezegun import freeze_time import pytest @@ -22,7 +23,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, @@ -33,12 +34,30 @@ from .common import ( async_wait_recording_done, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the recorder to use different chunk sizes for start time query. + + In effect this forces get_significant_states_with_session + to call _generate_significant_states_with_session_stmt multiple times. + """ + with patch( + "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -429,6 +448,7 @@ async def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. @@ -443,6 +463,7 @@ async def test_get_significant_states(hass: HomeAssistant) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_minimal_response( hass: HomeAssistant, ) -> None: @@ -512,6 +533,7 @@ async def test_get_significant_states_minimal_response( ) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) async def test_get_significant_states_with_initial( time_zone, hass: HomeAssistant @@ -544,6 +566,7 @@ async def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_without_initial( hass: HomeAssistant, ) -> None: @@ -578,6 +601,7 @@ async def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_entity_id( hass: HomeAssistant, ) -> None: @@ -596,6 +620,7 @@ async def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_multiple_entity_ids( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 666626ff688..908a67cd635 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -17,7 +18,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, @@ -28,12 +29,12 @@ from .common import ( old_db_schema, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -50,9 +51,9 @@ def disable_states_meta_manager(): @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 85badeea281..20d0c162d35 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -10,15 +11,15 @@ from unittest.mock import sentinel from freezegun import freeze_time import pytest +from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope -import homeassistant.core as ha from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, @@ -31,20 +32,20 @@ from .common import ( ) from .db_schema_42 import StateAttributes, States, StatesMeta -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @pytest.fixture(autouse=True) -def db_schema_42(): +def db_schema_42(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 42.""" - with old_db_schema("42"): + with old_db_schema(hass, "42"): yield diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 74d8861ae1e..95cd959db3b 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -85,6 +85,7 @@ from homeassistant.util.json import json_loads from .common import ( async_block_recorder, async_recorder_block_till_done, + async_wait_recorder, async_wait_recording_done, convert_pending_states_to_meta, corrupt_db_file, @@ -98,12 +99,12 @@ from tests.common import ( async_test_home_assistant, mock_platform, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -155,7 +156,7 @@ async def test_shutdown_before_startup_finishes( recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass, config)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) instance = get_instance(hass) session = await instance.async_add_executor_job(instance.get_session) @@ -188,7 +189,7 @@ async def test_canceled_before_startup_finishes( hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) instance = get_instance(hass) instance._hass_started.cancel() @@ -240,7 +241,7 @@ async def test_state_gets_saved_when_set_before_start_event( recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) entity_id = "test.recorder" state = "restoring_from_db" @@ -1373,7 +1374,7 @@ async def test_statistics_runs_initiated( @pytest.mark.parametrize("enable_missing_statistics", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_compile_missing_statistics( - async_test_recorder: RecorderInstanceGenerator, freezer: FrozenDateTimeFactory + async_test_recorder: RecorderInstanceContextManager, freezer: FrozenDateTimeFactory ) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) @@ -1632,7 +1633,7 @@ async def test_service_disable_states_not_recording( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_service_disable_run_information_recorded( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test that runs are still recorded when recorder is disabled.""" @@ -2655,9 +2656,9 @@ async def test_setup_fails_after_downgrade( await hass.async_stop() assert instance.engine is None assert ( - f"The database schema version {SCHEMA_VERSION+1} is newer than {SCHEMA_VERSION}" - " which is the maximum database schema version supported by the installed " - "version of Home Assistant Core" + f"The database schema version {SCHEMA_VERSION + 1} is newer " + f"than {SCHEMA_VERSION} which is the maximum database schema " + "version supported by the installed version of Home Assistant Core" ) in caplog.text @@ -2724,7 +2725,7 @@ async def test_commit_before_commits_pending_writes( recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass, config)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) instance = get_instance(hass) assert instance.commit_interval == 60 verify_states_in_queue_future = hass.loop.create_future() diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 052e9202715..035fd9b4440 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -30,19 +30,18 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import recorder as recorder_helper -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from .common import async_wait_recording_done, create_engine_test +from .common import async_wait_recorder, async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -641,7 +640,7 @@ async def test_schema_migrate( ) await hass.async_add_executor_job(instrument_migration.migration_started.wait) assert recorder.util.async_migration_in_progress(hass) is True - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) assert recorder.util.async_migration_in_progress(hass) is True assert recorder.util.async_migration_is_live(hass) == live diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 0624955b0e9..012e227c11a 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -41,7 +41,7 @@ from homeassistant.components.recorder.util import ( session_scope, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( @@ -52,7 +52,7 @@ from .common import ( from .conftest import instrument_migration from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @@ -60,7 +60,7 @@ SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -124,7 +124,7 @@ def db_schema_32(): @pytest.mark.parametrize("indices_to_drop", [[], [("events", "ix_events_context_id")]]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_events_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" @@ -225,6 +225,7 @@ async def test_migrate_events_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventsContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -282,6 +283,7 @@ async def test_migrate_events_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -396,7 +398,7 @@ async def test_migrate_events_context_ids( @pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_finish_migrate_events_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we re migrate old uuid context ids and ulid context ids to binary format. @@ -505,7 +507,7 @@ async def test_finish_migrate_events_context_ids( @pytest.mark.parametrize("indices_to_drop", [[], [("states", "ix_states_context_id")]]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_states_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" @@ -588,6 +590,7 @@ async def test_migrate_states_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.StatesContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -640,6 +643,7 @@ async def test_migrate_states_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -758,7 +762,7 @@ async def test_migrate_states_context_ids( @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_finish_migrate_states_context_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we re migrate old uuid context ids and ulid context ids to binary format. @@ -866,7 +870,7 @@ async def test_finish_migrate_states_context_ids( @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_event_type_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate event_types to the EventTypes table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -984,7 +988,7 @@ async def test_migrate_event_type_ids( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_entity_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1092,7 +1096,7 @@ async def test_migrate_entity_ids( ) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_post_migrate_entity_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, indices_to_drop: list[tuple[str, str]], ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" @@ -1127,6 +1131,7 @@ async def test_post_migrate_entity_ids( patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EntityIDMigration, "migrate_data"), patch.object(migration.EntityIDPostMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -1158,9 +1163,12 @@ async def test_post_migrate_entity_ids( return {state.state: state.entity_id for state in states} # Run again with new schema, let migration run - with patch( - "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create - ) as wrapped_idx_create: + with ( + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), + ): async with ( async_test_home_assistant() as hass, async_test_recorder(hass) as instance, @@ -1169,7 +1177,6 @@ async def test_post_migrate_entity_ids( await hass.async_block_till_done() await async_wait_recording_done(hass) - await async_wait_recording_done(hass) states_by_state = await instance.async_add_executor_job( _fetch_migrated_states @@ -1200,7 +1207,7 @@ async def test_post_migrate_entity_ids( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_entity_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1310,7 +1317,7 @@ async def test_migrate_null_entity_ids( @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_null_event_type_ids( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" importlib.import_module(SCHEMA_MODULE_32) @@ -1991,7 +1998,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_stats_migrate_times( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate times in the statistics tables.""" @@ -2147,7 +2154,7 @@ async def test_stats_migrate_times( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_cleanup_unmigrated_state_timestamps( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Ensure schema 48 migration cleans up any unmigrated state timestamps.""" importlib.import_module(SCHEMA_MODULE_32) diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 677abd6083c..350126b4c72 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from .common import async_recorder_block_till_done, async_wait_recording_done from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" @@ -34,7 +34,7 @@ SCHEMA_MODULE_CURRENT = "homeassistant.components.recorder.db_schema" @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -115,7 +115,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 1), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, [ @@ -131,7 +131,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], @@ -143,13 +143,43 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (0, 0), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], ), ( 38, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 43, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + # Schema was not bumped when the SQLite + # table rebuild was implemented so we need + # run event_id_post_migration up until + # schema 44 since its the first one we can + # be sure has the foreign key constraint was removed + # via https://github.com/home-assistant/core/pull/120779 + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 44, { "state_context_id_as_binary": (0, 0), "event_context_id_as_binary": (0, 0), @@ -175,7 +205,7 @@ def _create_engine_test( ], ) async def test_data_migrator_logic( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, initial_version: int, expected_migrator_calls: dict[str, tuple[int, int]], expected_created_indices: list[str], @@ -266,15 +296,21 @@ async def test_data_migrator_logic( # the expected number of times. for migrator, mock in migrator_mocks.items(): needs_migrate_calls, migrate_data_calls = expected_migrator_calls[migrator] - assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls - assert len(mock["migrate_data"].mock_calls) == migrate_data_calls + assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls, ( + f"Expected {migrator} needs_migrate to be called {needs_migrate_calls} times," + f" got {len(mock['needs_migrate'].mock_calls)}" + ) + assert len(mock["migrate_data"].mock_calls) == migrate_data_calls, ( + f"Expected {migrator} migrate_data to be called {migrate_data_calls} times, " + f"got {len(mock['migrate_data'].mock_calls)}" + ) @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migration_changes_prevent_trying_to_migrate_again( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Test that we do not try to migrate when migration_changes indicate its already migrated. diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index b2894883ff2..689441260c7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -5,6 +5,7 @@ from unittest.mock import PropertyMock import pytest +from homeassistant import core as ha from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( EventData, @@ -18,7 +19,6 @@ from homeassistant.components.recorder.models import ( process_timestamp_to_utc_isoformat, ) from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c3ff5027b70..e5eea0cf89f 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -45,7 +45,7 @@ from .common import ( convert_pending_states_to_meta, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager TEST_EVENT_TYPES = ( "EVENT_TEST_AUTOPURGE", @@ -59,7 +59,7 @@ TEST_EVENT_TYPES = ( @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index d68d1550268..0212e4b012e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -47,20 +47,20 @@ from .db_schema_32 import ( StatisticsShortTerm, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6b1e1a655db..ed883c5403e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,5 +1,6 @@ """The tests for sensor recorder platform.""" +from collections.abc import Generator from datetime import timedelta from typing import Any from unittest.mock import ANY, Mock, patch @@ -18,7 +19,8 @@ from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, - _generate_statistics_at_time_stmt, + _generate_statistics_at_time_stmt_dependent_sub_query, + _generate_statistics_at_time_stmt_group_by, _generate_statistics_during_period_stmt, async_add_external_statistics, async_import_statistics, @@ -41,7 +43,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, @@ -54,12 +56,30 @@ from .common import ( ) from tests.common import MockPlatform, mock_platform -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import RecorderInstanceContextManager, WebSocketGenerator + + +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the statistics query to use different chunk sizes for start time query. + + In effect this forces _statistics_at_time + to call _generate_statistics_at_time_stmt_group_by multiple times. + """ + with patch( + "homeassistant.components.recorder.statistics.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -1113,6 +1133,7 @@ async def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_daily_statistics_sum( @@ -1293,6 +1314,215 @@ async def test_daily_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_multiple_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 1", + "source": "test", + "statistic_id": "test:total_energy_import2", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 2", + "source": "test", + "statistic_id": "test:total_energy_import1", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata1, external_statistics) + async_add_external_statistics(hass, external_metadata2, external_statistics) + + await async_wait_recording_done(hass) + stats = statistics_during_period( + hass, + zero, + period="day", + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + expected_stats = { + "test:total_energy_import1": expected_stats_inner, + "test:total_energy_import2": expected_stats_inner, + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + types={"change"}, + ) + expected_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + assert stats == { + "test:total_energy_import1": expected_inner, + "test:total_energy_import2": expected_inner, + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={ + "not", + "the", + "same", + "test:total_energy_import1", + "test:total_energy_import2", + }, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=[ + "test:total_energy_import1", + "with_other", + "test:total_energy_import2", + ], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_mean( @@ -1428,6 +1658,7 @@ async def test_weekly_statistics_mean( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_sum( @@ -1608,6 +1839,7 @@ async def test_weekly_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") async def test_monthly_statistics_sum( @@ -1914,20 +2146,43 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt() -> None: - """Test cache key for _generate_statistics_at_time_stmt.""" - stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) +def test_cache_key_for_generate_statistics_at_time_stmt_group_by() -> None: + """Test cache key for _generate_statistics_at_time_stmt_group_by.""" + stmt = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) + stmt2 = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - stmt3 = _generate_statistics_at_time_stmt( + stmt3 = _generate_statistics_at_time_stmt_group_by( StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 +def test_cache_key_for_generate_statistics_at_time_stmt_dependent_sub_query() -> None: + """Test cache key for _generate_statistics_at_time_stmt_dependent_sub_query.""" + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + stmt3 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change( @@ -2263,6 +2518,392 @@ async def test_change( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_change_multiple( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + await async_wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + expected_stats = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + expected_stats_change_sum = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + expected_stats_wh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + expected_stats_mwh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_wh["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats_wh["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_mwh[ + "sensor.total_energy_import1" + ][1:4], + "sensor.total_energy_import2": expected_stats_mwh[ + "sensor.total_energy_import2" + ][1:4], + } + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:3 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:3 + ], + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 3:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 3:4 + ], + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change_with_none( diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index 1f9be0cabee..49b8836af70 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -17,7 +17,7 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.util import session_scope -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( CREATE_ENGINE_TARGET, @@ -27,7 +27,7 @@ from .common import ( ) from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager SCHEMA_VERSION_POSTFIX = "23_with_newer_columns" SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) @@ -37,7 +37,8 @@ SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX) @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates( - async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture + async_test_recorder: RecorderInstanceContextManager, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics. @@ -224,7 +225,8 @@ async def test_delete_duplicates( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates_many( - async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture + async_test_recorder: RecorderInstanceContextManager, + caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of duplicated statistics. @@ -418,7 +420,7 @@ async def test_delete_duplicates_many( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.parametrize("persistent_database", [True]) async def test_delete_duplicates_non_identical( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, tmp_path: Path, ) -> None: @@ -613,7 +615,7 @@ async def test_delete_duplicates_non_identical( @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) @pytest.mark.usefixtures("skip_by_db_engine") async def test_delete_duplicates_short_term( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, tmp_path: Path, ) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index aeeeba1865a..6c324f4b01a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -35,7 +35,6 @@ from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.util import ( MIN_VERSION_SQLITE, RETRYABLE_MYSQL_ERRORS, - UPCOMING_MIN_VERSION_SQLITE, database_job_retry_wrapper, end_incomplete_runs, is_second_sunday, @@ -56,12 +55,12 @@ from .common import ( ) from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager, RecorderInstanceGenerator @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder.""" @@ -236,7 +235,7 @@ def test_setup_connection_for_dialect_mysql(mysql_version) -> None: @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: """Test setting up the connection for a sqlite dialect.""" @@ -289,7 +288,7 @@ def test_setup_connection_for_dialect_sqlite(sqlite_version: str) -> None: @pytest.mark.parametrize( "sqlite_version", - [str(UPCOMING_MIN_VERSION_SQLITE)], + [str(MIN_VERSION_SQLITE)], ) def test_setup_connection_for_dialect_sqlite_zero_commit_interval( sqlite_version: str, @@ -418,7 +417,12 @@ def test_supported_mysql(caplog: pytest.LogCaptureFixture, mysql_version) -> Non dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "mysql", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "mysql", dbapi_connection, True + ) + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is True assert "minimum supported version" not in caplog.text @@ -503,6 +507,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -510,11 +515,11 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non [ ( "3.30.0", - "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 3.30.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ( "2.0.0", - "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.31.0.", + "Version 2.0.0 of SQLite is not supported; minimum supported version is 3.40.1.", ), ], ) @@ -552,8 +557,8 @@ def test_fail_outdated_sqlite( @pytest.mark.parametrize( "sqlite_version", [ - ("3.31.0"), - ("3.33.0"), + ("3.40.1"), + ("3.41.0"), ], ) def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> None: @@ -584,6 +589,7 @@ def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> N assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -676,6 +682,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -732,63 +739,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False - - -async def test_issue_for_old_sqlite( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test we create and delete an issue for old sqlite versions.""" - instance_mock = MagicMock() - instance_mock.hass = hass - execute_args = [] - close_mock = MagicMock() - min_version = str(MIN_VERSION_SQLITE) - - def execute_mock(statement): - nonlocal execute_args - execute_args.append(statement) - - def fetchall_mock(): - nonlocal execute_args - if execute_args[-1] == "SELECT sqlite_version()": - return [[min_version]] - return None - - def _make_cursor_mock(*_): - return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) - - dbapi_connection = MagicMock(cursor=_make_cursor_mock) - - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is not None - assert issue.translation_placeholders == { - "min_version": str(UPCOMING_MIN_VERSION_SQLITE), - "server_version": min_version, - } - - min_version = str(UPCOMING_MIN_VERSION_SQLITE) - database_engine = await hass.async_add_executor_job( - util.setup_connection_for_dialect, - instance_mock, - "sqlite", - dbapi_connection, - True, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue(DOMAIN, "sqlite_too_old") - assert issue is None - assert database_engine is not None + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 21f7037c370..c4c1285990d 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -17,13 +17,13 @@ from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Event, EventOrigin, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done from .conftest import instrument_migration from tests.common import async_test_home_assistant -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" SCHEMA_MODULE_30 = "tests.components.recorder.db_schema_30" @@ -73,7 +73,7 @@ def _create_engine_test( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_times( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate times in the events and states tables. @@ -240,7 +240,7 @@ async def test_migrate_times( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_entity_id_post_migration( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -351,7 +351,7 @@ async def test_migrate_can_resume_entity_id_post_migration( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_ix_states_event_id_removed( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -490,7 +490,7 @@ async def test_migrate_can_resume_ix_states_event_id_removed( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_out_of_disk_space_while_rebuild_states_table( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -670,7 +670,7 @@ async def test_out_of_disk_space_while_rebuild_states_table( @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_out_of_disk_space_while_removing_foreign_key( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 403384aee9f..a4e35bc8753 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -27,11 +27,12 @@ from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( async_recorder_block_till_done, + async_wait_recorder, async_wait_recording_done, create_engine_test, do_adhoc_statistics, @@ -41,7 +42,11 @@ from .common import ( from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import ( + RecorderInstanceContextManager, + RecorderInstanceGenerator, + WebSocketGenerator, +) @pytest.fixture @@ -2557,6 +2562,7 @@ async def test_recorder_info( assert response["success"] assert response["result"] == { "backlog": 0, + "db_in_default_location": False, # We never use the default URL in tests "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, @@ -2565,6 +2571,44 @@ async def test_recorder_info( } +@pytest.mark.parametrize( + ("db_url", "db_in_default_location"), + [ + ("sqlite:///{config_dir}/home-assistant_v2.db", True), + ("sqlite:///{config_dir}/custom.db", False), + ("mysql://root:root_password@127.0.0.1:3316/homeassistant-test", False), + ], +) +async def test_recorder_info_default_url( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + db_url: str, + db_in_default_location: bool, +) -> None: + """Test getting recorder status.""" + client = await hass_ws_client() + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + with patch.object( + recorder_mock, "db_url", db_url.format(config_dir=hass.config.config_dir) + ): + await client.send_json_auto_id({"type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "backlog": 0, + "db_in_default_location": db_in_default_location, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } + + async def test_recorder_info_no_recorder( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2603,27 +2647,35 @@ async def test_recorder_info_bad_recorder_config( assert response["result"]["thread_running"] is False -async def test_recorder_info_no_instance( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +async def test_recorder_info_wait_database_connect( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: - """Test getting recorder when there is no instance.""" + """Test getting recorder info waits for recorder database connection.""" client = await hass_ws_client() - with patch( - "homeassistant.components.recorder.basic_websocket_api.get_instance", - return_value=None, - ): - await client.send_json_auto_id({"type": "recorder/info"}) + recorder_helper.async_initialize_recorder(hass) + await client.send_json_auto_id({"type": "recorder/info"}) + + async with async_test_recorder(hass): response = await client.receive_json() assert response["success"] - assert response["result"]["recording"] is False - assert response["result"]["thread_running"] is False + assert response["result"] == { + "backlog": ANY, + "db_in_default_location": False, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } async def test_recorder_info_migration_queue_exhausted( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, instrument_migration: InstrumentedMigration, ) -> None: """Test getting recorder status when recorder queue is exhausted.""" @@ -2646,7 +2698,7 @@ async def test_recorder_info_migration_queue_exhausted( instrument_migration.migration_started.wait ) assert recorder.util.async_migration_in_progress(hass) is True - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) hass.states.async_set("my.entity", "on", {}) await hass.async_block_till_done() diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py new file mode 100644 index 00000000000..ac80cf2972b --- /dev/null +++ b/tests/components/remember_the_milk/conftest.py @@ -0,0 +1,48 @@ +"""Provide common pytest fixtures.""" + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from .const import TOKEN + + +@pytest.fixture(name="client") +def client_fixture() -> Generator[MagicMock]: + """Create a mock client.""" + client = MagicMock() + with ( + patch( + "homeassistant.components.remember_the_milk.entity.Rtm" + ) as entity_client_class, + patch("homeassistant.components.remember_the_milk.Rtm") as client_class, + ): + entity_client_class.return_value = client + client_class.return_value = client + client.token = TOKEN + client.token_valid.return_value = True + timelines = MagicMock() + timelines.timeline.value = "1234" + client.rtm.timelines.create.return_value = timelines + add_response = MagicMock() + add_response.list.id = "1" + add_response.list.taskseries.id = "2" + add_response.list.taskseries.task.id = "3" + client.rtm.tasks.add.return_value = add_response + + yield client + + +@pytest.fixture +async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]: + """Mock the config storage.""" + with patch( + "homeassistant.components.remember_the_milk.RememberTheMilkConfiguration" + ) as storage_class: + storage = storage_class.return_value + storage.get_token.return_value = TOKEN + storage.get_rtm_id.return_value = None + yield storage diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 8423c7f4651..bed39eec5f8 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,12 +3,17 @@ import json PROFILE = "myprofile" +CONFIG = { + "name": f"{PROFILE}", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} TOKEN = "mytoken" JSON_STRING = json.dumps( { "myprofile": { "token": "mytoken", - "id_map": {"1234": {"list_id": "0", "timeseries_id": "1", "task_id": "2"}}, + "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, } } ) diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py new file mode 100644 index 00000000000..bdd4189e394 --- /dev/null +++ b/tests/components/remember_the_milk/test_entity.py @@ -0,0 +1,276 @@ +"""Test the Remember The Milk entity.""" + +from typing import Any +from unittest.mock import MagicMock, call + +import pytest +from rtmapi import RtmRequestFailedException + +from homeassistant.components.remember_the_milk import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CONFIG, PROFILE + + +@pytest.mark.parametrize( + ("valid_token", "entity_state"), [(True, "ok"), (False, "API token invalid")] +) +async def test_entity_state( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + valid_token: bool, + entity_state: str, +) -> None: + """Test the entity state.""" + client.token_valid.return_value = valid_token + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + entity_id = f"{DOMAIN}.{PROFILE}" + state = hass.states.get(entity_id) + + assert state + assert state.state == entity_state + + +@pytest.mark.parametrize( + ( + "get_rtm_id_return_value", + "service", + "service_data", + "get_rtm_id_call_count", + "get_rtm_id_call_args", + "timelines_call_count", + "api_method", + "api_method_call_count", + "api_method_call_args", + "storage_method", + "storage_method_call_count", + "storage_method_call_args", + ), + [ + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + 0, + None, + 1, + "rtm.tasks.add", + 1, + call( + timeline="1234", + name="Test 1", + parse="1", + ), + "set_rtm_id", + 0, + None, + ), + ( + None, + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + 1, + call(PROFILE, "test_1"), + 1, + "rtm.tasks.add", + 1, + call( + timeline="1234", + name="Test 1", + parse="1", + ), + "set_rtm_id", + 1, + call(PROFILE, "test_1", "1", "2", "3"), + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + 1, + call(PROFILE, "test_1"), + 1, + "rtm.tasks.setName", + 1, + call( + name="Test 1", + list_id="1", + taskseries_id="2", + task_id="3", + timeline="1234", + ), + "set_rtm_id", + 0, + None, + ), + ( + ("1", "2", "3"), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + 1, + call(PROFILE, "test_1"), + 1, + "rtm.tasks.complete", + 1, + call( + list_id="1", + taskseries_id="2", + task_id="3", + timeline="1234", + ), + "delete_rtm_id", + 1, + call(PROFILE, "test_1"), + ), + ], +) +async def test_services( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + get_rtm_id_return_value: Any, + service: str, + service_data: dict[str, Any], + get_rtm_id_call_count: int, + get_rtm_id_call_args: tuple[tuple, dict] | None, + timelines_call_count: int, + api_method: str, + api_method_call_count: int, + api_method_call_args: tuple[tuple, dict], + storage_method: str, + storage_method_call_count: int, + storage_method_call_args: tuple[tuple, dict] | None, +) -> None: + """Test create and complete task service.""" + storage.get_rtm_id.return_value = get_rtm_id_return_value + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + + assert storage.get_rtm_id.call_count == get_rtm_id_call_count + assert storage.get_rtm_id.call_args == get_rtm_id_call_args + assert client.rtm.timelines.create.call_count == timelines_call_count + client_method = client + for name in api_method.split("."): + client_method = getattr(client_method, name) + assert client_method.call_count == api_method_call_count + assert client_method.call_args == api_method_call_args + storage_method_attribute = getattr(storage, storage_method) + assert storage_method_attribute.call_count == storage_method_call_count + assert storage_method_attribute.call_args == storage_method_call_args + + +@pytest.mark.parametrize( + ( + "get_rtm_id_return_value", + "service", + "service_data", + "method", + "exception", + "error_message", + ), + [ + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + "rtm.tasks.add", + RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), + "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + ), + ( + None, + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + None, + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.tasks.add", + RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), + "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.tasks.setName", + RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"), + "Request rtm.tasks.setName failed. Status: 400, reason: Bad request.", + ), + ( + None, + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.timelines.create", + None, + ( + f"Could not find task with ID test_1 in account {PROFILE}. " + "So task could not be closed" + ), + ), + ( + ("1", "2", "3"), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.tasks.complete", + RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"), + "Request rtm.tasks.complete failed. Status: 400, reason: Bad request.", + ), + ], +) +async def test_services_errors( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + caplog: pytest.LogCaptureFixture, + get_rtm_id_return_value: Any, + service: str, + service_data: dict[str, Any], + method: str, + exception: Exception, + error_message: str, +) -> None: + """Test create and complete task service errors.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + storage.get_rtm_id.return_value = get_rtm_id_return_value + + client_method = client + for name in method.split("."): + client_method = getattr(client_method, name) + + client_method.side_effect = exception + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + + assert error_message in caplog.text diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index c68fe14430a..feed2894d86 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -1,70 +1,65 @@ -"""Tests for the Remember The Milk component.""" +"""Test the Remember The Milk integration.""" -from unittest.mock import Mock, mock_open, patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch -import homeassistant.components.remember_the_milk as rtm +import pytest + +from homeassistant.components.remember_the_milk import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from .const import JSON_STRING, PROFILE, TOKEN +from .const import CONFIG, PROFILE, TOKEN -def test_create_new(hass: HomeAssistant) -> None: - """Test creating a new config file.""" - with ( - patch("builtins.open", mock_open()), - patch("os.path.isfile", Mock(return_value=False)), - patch.object(rtm.RememberTheMilkConfiguration, "save_config"), - ): - config = rtm.RememberTheMilkConfiguration(hass) - config.set_token(PROFILE, TOKEN) - assert config.get_token(PROFILE) == TOKEN +@pytest.fixture(autouse=True) +def configure_id() -> Generator[str]: + """Fixture to return a configure_id.""" + mock_id = "1-1" + with patch( + "homeassistant.components.configurator.Configurator._generate_unique_id" + ) as generate_id: + generate_id.return_value = mock_id + yield mock_id -def test_load_config(hass: HomeAssistant) -> None: - """Test loading an existing token from the file.""" - with ( - patch("builtins.open", mock_open(read_data=JSON_STRING)), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config.get_token(PROFILE) == TOKEN +@pytest.mark.parametrize( + ("token", "rtm_entity_exists", "configurator_end_state"), + [(TOKEN, True, "configured"), (None, False, "configure")], +) +async def test_configurator( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + configure_id: str, + token: str | None, + rtm_entity_exists: bool, + configurator_end_state: str, +) -> None: + """Test configurator.""" + storage.get_token.return_value = None + client.authenticate_desktop.return_value = ("test-url", "test-frob") + client.token = token + rtm_entity_id = f"{DOMAIN}.{PROFILE}" + configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.async_block_till_done() -def test_invalid_data(hass: HomeAssistant) -> None: - """Test starts with invalid data and should not raise an exception.""" - with ( - patch("builtins.open", mock_open(read_data="random characters")), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config is not None + assert hass.states.get(rtm_entity_id) is None + state = hass.states.get(configure_entity_id) + assert state + assert state.state == "configure" + await hass.services.async_call( + "configurator", + "configure", + {"configure_id": configure_id}, + blocking=True, + ) + await hass.async_block_till_done() -def test_id_map(hass: HomeAssistant) -> None: - """Test the hass to rtm task is mapping.""" - hass_id = "hass-id-1234" - list_id = "mylist" - timeseries_id = "my_timeseries" - rtm_id = "rtm-id-4567" - with ( - patch("builtins.open", mock_open()), - patch("os.path.isfile", Mock(return_value=False)), - patch.object(rtm.RememberTheMilkConfiguration, "save_config"), - ): - config = rtm.RememberTheMilkConfiguration(hass) - - assert config.get_rtm_id(PROFILE, hass_id) is None - config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id) - assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id) - config.delete_rtm_id(PROFILE, hass_id) - assert config.get_rtm_id(PROFILE, hass_id) is None - - -def test_load_key_map(hass: HomeAssistant) -> None: - """Test loading an existing key map from the file.""" - with ( - patch("builtins.open", mock_open(read_data=JSON_STRING)), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config.get_rtm_id(PROFILE, "1234") == ("0", "1", "2") + assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists + state = hass.states.get(configure_entity_id) + assert state + assert state.state == configurator_end_state diff --git a/tests/components/remember_the_milk/test_storage.py b/tests/components/remember_the_milk/test_storage.py new file mode 100644 index 00000000000..6ae774a3d0d --- /dev/null +++ b/tests/components/remember_the_milk/test_storage.py @@ -0,0 +1,131 @@ +"""Tests for the Remember The Milk integration.""" + +import json +from unittest.mock import mock_open, patch + +import pytest + +from homeassistant.components import remember_the_milk as rtm +from homeassistant.core import HomeAssistant + +from .const import JSON_STRING, PROFILE, TOKEN + + +def test_set_get_delete_token(hass: HomeAssistant) -> None: + """Test set, get and delete token.""" + open_mock = mock_open() + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): + config = rtm.RememberTheMilkConfiguration(hass) + assert open_mock.return_value.write.call_count == 0 + assert config.get_token(PROFILE) is None + assert open_mock.return_value.write.call_count == 0 + config.set_token(PROFILE, TOKEN) + assert open_mock.return_value.write.call_count == 1 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": {}, + "token": "mytoken", + } + } + ) + assert config.get_token(PROFILE) == TOKEN + assert open_mock.return_value.write.call_count == 1 + config.delete_token(PROFILE) + assert open_mock.return_value.write.call_count == 2 + assert open_mock.return_value.write.call_args[0][0] == json.dumps({}) + assert config.get_token(PROFILE) is None + assert open_mock.return_value.write.call_count == 2 + + +def test_config_load(hass: HomeAssistant) -> None: + """Test loading from the file.""" + with ( + patch( + "homeassistant.components.remember_the_milk.storage.Path.open", + mock_open(read_data=JSON_STRING), + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is not None + assert rtm_id == ("1", "2", "3") + + +@pytest.mark.parametrize( + "side_effect", [FileNotFoundError("Missing file"), OSError("IO error")] +) +def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> None: + """Test loading with file error.""" + config = rtm.RememberTheMilkConfiguration(hass) + with ( + patch( + "homeassistant.components.remember_the_milk.storage.Path.open", + side_effect=side_effect, + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + # The config should be empty and we should not have any errors + # when trying to access it. + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is None + + +def test_config_load_invalid_data(hass: HomeAssistant) -> None: + """Test loading invalid data.""" + config = rtm.RememberTheMilkConfiguration(hass) + with ( + patch( + "homeassistant.components.remember_the_milk.storage.Path.open", + mock_open(read_data="random characters"), + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + # The config should be empty and we should not have any errors + # when trying to access it. + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is None + + +def test_config_set_delete_id(hass: HomeAssistant) -> None: + """Test setting and deleting an id from the config.""" + hass_id = "123" + list_id = "1" + timeseries_id = "2" + rtm_id = "3" + open_mock = mock_open() + config = rtm.RememberTheMilkConfiguration(hass) + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): + config = rtm.RememberTheMilkConfiguration(hass) + assert open_mock.return_value.write.call_count == 0 + assert config.get_rtm_id(PROFILE, hass_id) is None + assert open_mock.return_value.write.call_count == 0 + config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id) + assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id) + assert open_mock.return_value.write.call_count == 1 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": { + "123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"} + } + } + } + ) + config.delete_rtm_id(PROFILE, hass_id) + assert config.get_rtm_id(PROFILE, hass_id) is None + assert open_mock.return_value.write.call_count == 2 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": {}, + } + } + ) diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 6c9334aeac4..b4dd513c317 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index c647faba2c1..800d090fd7b 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index 7142608b977..b62cfb4d1b1 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +74,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -134,6 +138,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,6 +170,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -196,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +311,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -341,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -372,6 +381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -403,6 +413,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -434,6 +445,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -465,6 +477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -496,6 +509,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -527,6 +541,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -690,6 +706,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -727,6 +744,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +776,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -789,6 +808,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -860,6 +880,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -897,6 +918,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -928,6 +950,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -959,6 +982,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -990,6 +1014,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1021,6 +1046,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1052,6 +1078,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1083,6 +1110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1114,6 +1142,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1145,6 +1174,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,6 +1318,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1325,6 +1356,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1356,6 +1388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1387,6 +1420,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1452,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1449,6 +1484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1480,6 +1516,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1588,6 +1625,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1625,6 +1663,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1656,6 +1695,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1687,6 +1727,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1718,6 +1759,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1749,6 +1791,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1780,6 +1823,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1811,6 +1855,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1842,6 +1887,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1974,6 +2020,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2011,6 +2058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2042,6 +2090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2073,6 +2122,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2144,6 +2194,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2181,6 +2232,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2212,6 +2264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2243,6 +2296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2274,6 +2328,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2305,6 +2360,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2336,6 +2392,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2367,6 +2424,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2398,6 +2456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2429,6 +2488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index e61255372c1..58789c7aa47 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -88,6 +90,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -125,6 +128,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +160,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -187,6 +192,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +262,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -293,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +332,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -355,6 +364,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -424,6 +434,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -461,6 +472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +536,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -592,6 +606,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -629,6 +644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +692,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -713,6 +730,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -744,6 +762,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -775,6 +794,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -844,6 +864,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -881,6 +902,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -912,6 +934,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -943,6 +966,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1012,6 +1036,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1049,6 +1074,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1080,6 +1106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1111,6 +1138,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index f90cb92cc63..119defca4ac 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -89,6 +91,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -126,6 +129,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -174,6 +178,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -216,6 +221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -253,6 +259,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -301,6 +308,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -338,6 +346,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +398,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -426,6 +436,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +488,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -519,6 +531,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -556,6 +569,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index 9974e21be75..526c8af5bc4 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -46,6 +47,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -90,6 +92,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -143,6 +146,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -187,6 +191,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -284,6 +290,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -337,6 +344,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -379,6 +387,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -423,6 +432,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +486,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -520,6 +531,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -573,6 +585,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -617,6 +630,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index b092222c9f3..175ad2422ed 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -43,6 +44,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -76,6 +78,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -140,6 +144,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -171,6 +176,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +321,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -353,6 +361,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -395,6 +404,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -500,6 +512,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,6 +546,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -566,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -599,6 +614,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +646,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +680,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -696,6 +714,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -760,6 +780,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -791,6 +812,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -822,6 +844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1071,6 +1094,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1110,6 +1134,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1152,6 +1177,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1185,6 +1211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1245,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1257,6 +1285,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1290,6 +1319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1323,6 +1353,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1356,6 +1387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1387,6 +1419,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1420,6 +1453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1453,6 +1487,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1484,6 +1519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1515,6 +1551,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1546,6 +1583,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1577,6 +1615,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1824,6 +1863,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1863,6 +1903,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1905,6 +1946,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1980,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1971,6 +2014,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2010,6 +2054,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2043,6 +2088,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2076,6 +2122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2109,6 +2156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2140,6 +2188,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2173,6 +2222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2206,6 +2256,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2237,6 +2288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2268,6 +2320,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2299,6 +2352,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2330,6 +2384,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2361,6 +2416,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2620,6 +2676,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2659,6 +2716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2692,6 +2750,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2725,6 +2784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2756,6 +2816,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -2787,6 +2848,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2818,6 +2880,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -2930,6 +2993,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2969,6 +3033,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3011,6 +3076,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3044,6 +3110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3077,6 +3144,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3116,6 +3184,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3149,6 +3218,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3182,6 +3252,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3215,6 +3286,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3246,6 +3318,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -3279,6 +3352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3312,6 +3386,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3345,6 +3420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3376,6 +3452,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -3407,6 +3484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3438,6 +3516,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -3687,6 +3766,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -3726,6 +3806,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3768,6 +3849,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3801,6 +3883,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3834,6 +3917,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3873,6 +3957,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3906,6 +3991,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3939,6 +4025,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3972,6 +4059,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4003,6 +4091,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4036,6 +4125,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4069,6 +4159,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4100,6 +4191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4131,6 +4223,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4162,6 +4255,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4193,6 +4287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4440,6 +4535,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -4479,6 +4575,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4521,6 +4618,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4554,6 +4652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4587,6 +4686,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4626,6 +4726,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4659,6 +4760,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4692,6 +4794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4725,6 +4828,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4756,6 +4860,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4789,6 +4894,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4822,6 +4928,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4853,6 +4960,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4884,6 +4992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4915,6 +5024,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4946,6 +5056,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4977,6 +5088,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 81865d98801..5af55b48dda 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" +TEST_PRIVACY = True @pytest.fixture @@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock = host_mock_class.return_value host_mock.get_host_data.return_value = None host_mock.get_states.return_value = None + host_mock.supported.return_value = True host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True @@ -113,6 +119,11 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) + + reolink_connect.chime_list = [] # enums host_mock.whiteled_mode.return_value = 1 @@ -126,7 +137,13 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan = create_autospec(Baichuan) # Disable tcp push by default for tests host_mock.baichuan.events_active = False + host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } + yield host_mock_class @@ -157,6 +174,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 71c5397fbd1..f8d5318e9bd 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -1,6 +1,27 @@ # serializer version: 1 # name: test_entry_diagnostics dict({ + 'BC_abilities': dict({ + '0': dict({ + 'aitype': 34615, + 'chnID': 0, + }), + 'Host': dict({ + 'pushAlarm': 7, + }), + }), + 'Chimes': dict({ + '12345678': dict({ + 'channel': 0, + 'event_types': list([ + 'md', + 'people', + 'visitor', + ]), + 'name': 'Test chime', + 'online': True, + }), + }), 'HTTP(S) port': 1234, 'HTTPS': True, 'IPC cams': dict({ @@ -41,6 +62,10 @@ 0, ]), 'cmd list': dict({ + 'DingDongOpt': dict({ + '0': 2, + 'null': 2, + }), 'GetAiAlarm': dict({ '0': 5, 'null': 5, @@ -81,6 +106,10 @@ '0': 2, 'null': 4, }), + 'GetDingDongCfg': dict({ + '0': 3, + 'null': 3, + }), 'GetEmail': dict({ '0': 1, 'null': 2, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 59342934c1c..4fe671f8cca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -2,7 +2,7 @@ import json from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory @@ -11,14 +11,18 @@ from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -32,6 +36,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, @@ -42,6 +47,7 @@ from .conftest import ( TEST_PASSWORD, TEST_PASSWORD2, TEST_PORT, + TEST_PRIVACY, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -81,6 +87,7 @@ async def test_config_flow_manual_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -88,6 +95,60 @@ async def test_config_flow_manual_success( assert result["result"].unique_id == TEST_MAC +async def test_config_flow_privacy_success( + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Successful flow when privacy mode is turned on.""" + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "privacy" + assert result["errors"] is None + + assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 + reolink_connect.get_host_data.reset_mock(side_effect=True) + + with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + } + assert result["options"] == { + CONF_PROTOCOL: DEFAULT_PROTOCOL, + } + assert result["result"].unique_id == TEST_MAC + + reolink_connect.baichuan.privacy_mode.return_value = False + + async def test_config_flow_errors( hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: @@ -240,6 +301,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -381,7 +443,7 @@ async def test_reauth_abort_unique_id_mismatch( async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -411,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -451,7 +514,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async_fire_time_changed(hass) await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -548,7 +611,7 @@ async def test_dhcp_ip_update( async_fire_time_changed(hass) await hass.async_block_till_done() - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, @@ -620,7 +683,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - dhcp_data = dhcp.DhcpServiceInfo( + dhcp_data = DhcpServiceInfo( ip=TEST_HOST2, hostname="Reolink", macaddress=DHCP_FORMATTED_MAC, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index 57b474c13ad..d45163d3cf0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from reolink_aio.api import Chime from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -15,6 +16,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, reolink_connect: MagicMock, + test_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index f851e13c91d..28d8c542f4f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,13 +1,18 @@ """Test the Reolink init.""" import asyncio +from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime -from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) from homeassistant.components.reolink import ( DEVICE_UPDATE_INTERVAL, @@ -16,7 +21,13 @@ from homeassistant.components.reolink import ( ) from homeassistant.components.reolink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PORT, STATE_OFF, STATE_UNAVAILABLE, Platform +from homeassistant.const import ( + CONF_PORT, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.helpers import ( @@ -749,3 +760,129 @@ async def test_port_changed( await hass.async_block_till_done() assert config_entry.data[CONF_PORT] == 4567 + + +async def test_privacy_mode_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test successful setup even when privacy mode is turned on.""" + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.get_states = AsyncMock( + side_effect=LoginPrivacyModeError("Test error") + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + reolink_connect.baichuan.privacy_mode.return_value = False + + +async def test_LoginPrivacyModeError( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test normal update when get_states returns a LoginPrivacyModeError.""" + reolink_connect.baichuan.privacy_mode.return_value = False + reolink_connect.get_states = AsyncMock( + side_effect=LoginPrivacyModeError("Test error") + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.baichuan.check_subscribe_events.reset_mock() + assert reolink_connect.baichuan.check_subscribe_events.call_count == 0 + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1 + + +async def test_privacy_mode_change_callback( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test privacy mode changed callback.""" + + class callback_mock_class: + callback_func = None + + def register_callback( + self, callback_id: str, callback: Callable[[], None], *args, **key_args + ) -> None: + if callback_id == "privacy_mode_change": + self.callback_func = callback + + callback_mock = callback_mock_class() + + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.events_active = True + reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_connect.baichuan.register_callback = callback_mock.register_callback + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.audio_record.return_value = True + reolink_connect.get_states = AsyncMock() + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # simulate a TCP push callback signaling a privacy mode change + reolink_connect.baichuan.privacy_mode.return_value = False + assert callback_mock.callback_func is not None + callback_mock.callback_func() + + # check that a coordinator update was scheduled. + reolink_connect.get_states.reset_mock() + assert reolink_connect.get_states.call_count == 0 + + freezer.tick(5) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.get_states.call_count >= 1 + assert hass.states.get(entity_id).state == STATE_ON + + # test cleanup during unloading, first reset to privacy mode ON + reolink_connect.baichuan.privacy_mode.return_value = True + callback_mock.callback_func() + freezer.tick(5) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # now fire the callback again, but unload before refresh took place + reolink_connect.baichuan.privacy_mode.return_value = False + callback_mock.callback_func() + await hass.async_block_till_done() + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_remove( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test removing of the reolink integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_remove(config_entry.entry_id) diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 9c5be08e9b6..a5a34514598 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -235,12 +235,12 @@ async def test_browsing( reolink_connect.model = TEST_HOST_MODEL -async def test_browsing_unsupported_encoding( +async def test_browsing_h265_encoding( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, ) -> None: - """Test browsing a Reolink camera with unsupported stream encoding.""" + """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): @@ -249,7 +249,6 @@ async def test_browsing_unsupported_encoding( browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" - # browse resolution select/camera recording days when main encoding unsupported mock_status = MagicMock() mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH @@ -261,6 +260,18 @@ async def test_browsing_unsupported_encoding( browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}" + browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub" + browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" + + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME}" + assert browse.identifier == browse_resolution_id + assert browse.children[0].identifier == browse_res_sub_id + assert browse.children[1].identifier == browse_res_main_id + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|sub" browse_day_0_id = ( f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 2e7c1556201..2b2c33f0e8f 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -29,6 +29,211 @@ from .conftest import TEST_CAM_NAME, TEST_NVR_NAME, TEST_UID from tests.common import MockConfigEntry, async_fire_time_changed +async def test_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, +) -> None: + """Test switch entity.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.audio_record.return_value = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.audio_record.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + # test switch turn on + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_audio.assert_called_with(0, True) + + reolink_connect.set_audio.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + reolink_connect.set_audio.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_audio.assert_called_with(0, False) + + reolink_connect.set_audio.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + reolink_connect.set_audio.reset_mock(side_effect=True) + + reolink_connect.camera_online.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + reolink_connect.camera_online.return_value = True + + +async def test_host_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, +) -> None: + """Test host switch entity.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.email_enabled.return_value = True + reolink_connect.is_hub = False + reolink_connect.supported.return_value = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_email_on_event" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.email_enabled.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + # test switch turn on + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_email.assert_called_with(None, True) + + reolink_connect.set_email.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + reolink_connect.set_email.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_email.assert_called_with(None, False) + + reolink_connect.set_email.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + reolink_connect.set_email.reset_mock(side_effect=True) + + +async def test_chime_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + test_chime: Chime, +) -> None: + """Test host switch entity.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.test_chime_led" + assert hass.states.get(entity_id).state == STATE_ON + + test_chime.led_state = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + # test switch turn on + test_chime.set_option = AsyncMock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + test_chime.set_option.assert_called_with(led=True) + + test_chime.set_option.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + test_chime.set_option.reset_mock(side_effect=True) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + test_chime.set_option.assert_called_with(led=False) + + test_chime.set_option.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + test_chime.set_option.reset_mock(side_effect=True) + + @pytest.mark.parametrize( ( "original_id", @@ -171,206 +376,3 @@ async def test_hub_switches_repair_issue( reolink_connect.is_hub = False reolink_connect.supported.return_value = True - - -async def test_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, -) -> None: - """Test switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.audio_record.return_value = True - - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" - assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.audio_record.return_value = False - freezer.tick(DEVICE_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_OFF - - # test switch turn on - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_audio.assert_called_with(0, True) - - reolink_connect.set_audio.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - # test switch turn off - reolink_connect.set_audio.reset_mock(side_effect=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_audio.assert_called_with(0, False) - - reolink_connect.set_audio.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - reolink_connect.set_audio.reset_mock(side_effect=True) - - reolink_connect.camera_online.return_value = False - freezer.tick(DEVICE_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - - reolink_connect.camera_online.return_value = True - - -async def test_host_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, -) -> None: - """Test host switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.email_enabled.return_value = True - - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_email_on_event" - assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.email_enabled.return_value = False - freezer.tick(DEVICE_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_OFF - - # test switch turn on - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_email.assert_called_with(None, True) - - reolink_connect.set_email.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - # test switch turn off - reolink_connect.set_email.reset_mock(side_effect=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_connect.set_email.assert_called_with(None, False) - - reolink_connect.set_email.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - reolink_connect.set_email.reset_mock(side_effect=True) - - -async def test_chime_switch( - hass: HomeAssistant, - config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, - test_chime: Chime, -) -> None: - """Test host switch entity.""" - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - entity_id = f"{Platform.SWITCH}.test_chime_led" - assert hass.states.get(entity_id).state == STATE_ON - - test_chime.led_state = False - freezer.tick(DEVICE_UPDATE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert hass.states.get(entity_id).state == STATE_OFF - - # test switch turn on - test_chime.set_option = AsyncMock() - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - test_chime.set_option.assert_called_with(led=True) - - test_chime.set_option.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - # test switch turn off - test_chime.set_option.reset_mock(side_effect=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - test_chime.set_option.assert_called_with(led=False) - - test_chime.set_option.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index e78563503f1..9c4a0dfbd2a 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -21,16 +21,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -170,14 +161,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -347,10 +331,7 @@ async def test_ignore_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, @@ -505,10 +486,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 399292fb83f..bbaf70e0a9b 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -151,10 +151,7 @@ async def mock_repairs_integration(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_dismiss_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -238,10 +235,7 @@ async def test_dismiss_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_non_existing_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -289,19 +283,19 @@ async def test_fix_non_existing_issue( @pytest.mark.parametrize( - ("domain", "step", "description_placeholders", "ignore_translations"), + ( + "domain", + "step", + "description_placeholders", + "ignore_translations_for_mock_domains", + ), [ - ( - "fake_integration", - "custom_step", - None, - ["component.fake_integration.issues.abc_123.title"], - ), + ("fake_integration", "custom_step", None, ["fake_integration"]), ( "fake_integration_default_handler", "confirm", {"abc": "123"}, - ["component.fake_integration_default_handler.issues.abc_123.title"], + ["fake_integration_default_handler"], ), ], ) @@ -398,10 +392,7 @@ async def test_fix_issue_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_get_progress_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -433,10 +424,7 @@ async def test_get_progress_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_step_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -468,16 +456,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, @@ -569,15 +548,7 @@ async def test_list_issues( } -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.fake_integration.issues.abc_123.title", - "component.fake_integration.issues.abc_123.fix_flow.abort.not_given", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -639,16 +610,7 @@ async def test_fix_issue_aborted( assert msg["result"]["issues"][0] == first_issue -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_get_issue_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 31ce261e312..2cd144976ce 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -591,7 +591,7 @@ async def test_update_with_no_template(hass: HomeAssistant) -> None: assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 state = hass.states.get("sensor.foo") - assert state.state == '{"key": "some_json_value"}' + assert state.state == '{"key":"some_json_value"}' @respx.mock diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 9329edb3a00..fd113bceaa0 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, State, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_init import mock_rflink diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 8f212b6e976..79736d418b5 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -58,17 +58,17 @@ async def test_one_pt2262(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await rfxtrx.signal("0913000022670e013970") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "on" await rfxtrx.signal("09130000226707013d70") - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state.state == "off" @@ -85,10 +85,10 @@ async def test_pt2262_unconfigured(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226707") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226707" state = hass.states.get("binary_sensor.pt2262_226707") assert state @@ -318,7 +318,7 @@ async def test_pt2262_duplicate_id(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 1e23bdaf982..6fd4fc14bc5 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -726,7 +726,6 @@ async def test_options_add_and_configure_device( result["flow_id"], user_input={ "data_bits": 4, - "off_delay": "abcdef", "command_on": "xyz", "command_off": "xyz", }, @@ -735,7 +734,6 @@ async def test_options_add_and_configure_device( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "set_device_options" assert result["errors"] - assert result["errors"]["off_delay"] == "invalid_input_off_delay" assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_off"] == "invalid_input_2262_off" @@ -745,7 +743,7 @@ async def test_options_add_and_configure_device( "data_bits": 4, "command_on": "0xE", "command_off": "0x7", - "off_delay": "9", + "off_delay": 9, }, ) @@ -758,10 +756,10 @@ async def test_options_add_and_configure_device( assert entry.data["devices"]["0913000022670e013970"] assert entry.data["devices"]["0913000022670e013970"]["off_delay"] == 9 - state = hass.states.get("binary_sensor.pt2262_22670e") + state = hass.states.get("binary_sensor.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 4336798768f..f17fd8743f1 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -330,10 +330,10 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await hass.async_block_till_done() await hass.async_start() - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "unknown" - assert state.attributes.get("friendly_name") == "PT2262 22670e Signal strength" + assert state.attributes.get("friendly_name") == "PT2262 226700 Signal strength" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -351,7 +351,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b70") await rfxtrx.signal("0b1100cd0213c7f230010f71") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-64" @@ -362,7 +362,7 @@ async def test_rssi_sensor(hass: HomeAssistant, rfxtrx) -> None: await rfxtrx.signal("0913000022670e013b60") await rfxtrx.signal("0b1100cd0213c7f230010f61") - state = hass.states.get("sensor.pt2262_22670e_signal_strength") + state = hass.states.get("sensor.pt2262_226700_signal_strength") assert state assert state.state == "-72" diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 7acc008cc8a..964c5ccb2e6 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -70,23 +70,23 @@ async def test_one_pt2262_switch(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_on", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "on" await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.pt2262_22670e"}, blocking=True + "switch", "turn_off", {"entity_id": "switch.pt2262_226700"}, blocking=True ) - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state.state == "off" assert rfxtrx.transport.send.mock_calls == [ @@ -220,26 +220,26 @@ async def test_pt2262_switch_events(hass: HomeAssistant, rfxtrx) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.pt2262_22670e") + state = hass.states.get("switch.pt2262_226700") assert state assert state.state == STATE_UNKNOWN - assert state.attributes.get("friendly_name") == "PT2262 22670e" + assert state.attributes.get("friendly_name") == "PT2262 226700" # "Command: 0xE" await rfxtrx.signal("0913000022670e013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x0" await rfxtrx.signal("09130000226700013970") - assert hass.states.get("switch.pt2262_22670e").state == "on" + assert hass.states.get("switch.pt2262_226700").state == "on" # "Command: 0x7" await rfxtrx.signal("09130000226707013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" # "Command: 0x1" await rfxtrx.signal("09130000226701013d70") - assert hass.states.get("switch.pt2262_22670e").state == "off" + assert hass.states.get("switch.pt2262_226700").state == "off" async def test_discover_switch(hass: HomeAssistant, rfxtrx_automatic) -> None: diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index b03d87c7a89..4b4dda7227d 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index 22fa1c2bf32..e7af1d94855 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -6,6 +6,7 @@ from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.ring import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, translation from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -35,3 +36,62 @@ async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> N } }, ) + + +async def async_check_entity_translations( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_id: str, + platform_domain: str, +) -> None: + """Check that entity translations are used correctly. + + Check no unused translations in strings. + Check no translation_key defined when translation not in strings. + Check no translation defined when device class translation can be used. + """ + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + + assert entity_entries + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Limit the loaded platforms to 1 platform." + ) + + translations = await translation.async_get_translations( + hass, "en", "entity", [DOMAIN] + ) + device_class_translations = await translation.async_get_translations( + hass, "en", "entity_component", [platform_domain] + ) + unique_device_classes = set() + used_translation_keys = set() + for entity_entry in entity_entries: + dc_translation = None + if entity_entry.original_device_class: + dc_translation_key = f"component.{platform_domain}.entity_component.{entity_entry.original_device_class.value}.name" + dc_translation = device_class_translations.get(dc_translation_key) + + if entity_entry.translation_key: + key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" + entity_translation = translations.get(key) + assert entity_translation, ( + f"Translation key {entity_entry.translation_key} defined for {entity_entry.entity_id} not in strings.json" + ) + assert dc_translation != entity_translation, ( + f"Translation {key} is defined the same as the device class translation." + ) + used_translation_keys.add(key) + + else: + unique_key = (entity_entry.device_id, entity_entry.original_device_class) + assert unique_key not in unique_device_classes, ( + f"No translation key and multiple entities using {entity_entry.original_device_class}" + ) + unique_device_classes.add(entity_entry.original_device_class) + + for defined_key in translations: + if defined_key.split(".")[3] != platform_domain: + continue + assert defined_key in used_translation_keys, ( + f"Translation key {defined_key} unused." + ) diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr index 2f8e4d8a219..09dab9b0ecc 100644 --- a/tests/components/ring/snapshots/test_binary_sensor.ambr +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,7 +77,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '987654-motion', 'unit_of_measurement': None, }) @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,7 +126,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '765432-motion', 'unit_of_measurement': None, }) @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -219,7 +224,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '345678-motion', 'unit_of_measurement': None, }) diff --git a/tests/components/ring/snapshots/test_button.ambr b/tests/components/ring/snapshots/test_button.ambr index 01f6525450b..7da11d66194 100644 --- a/tests/components/ring/snapshots/test_button.ambr +++ b/tests/components/ring/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_camera.ambr b/tests/components/ring/snapshots/test_camera.ambr index ec285b438b3..8c3b8a083b0 100644 --- a/tests/components/ring/snapshots/test_camera.ambr +++ b/tests/components/ring/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -112,6 +114,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -217,6 +221,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -270,6 +275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_event.ambr b/tests/components/ring/snapshots/test_event.ambr index e97a01516bb..9c0fee906a0 100644 --- a/tests/components/ring/snapshots/test_event.ambr +++ b/tests/components/ring/snapshots/test_event.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,6 +238,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +295,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_light.ambr b/tests/components/ring/snapshots/test_light.ambr index 73874fda259..6c6effb93c1 100644 --- a/tests/components/ring/snapshots/test_light.ambr +++ b/tests/components/ring/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_number.ambr b/tests/components/ring/snapshots/test_number.ambr index 0873319b837..abc63051f6a 100644 --- a/tests/components/ring/snapshots/test_number.ambr +++ b/tests/components/ring/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +125,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -235,6 +239,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -291,6 +296,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -347,6 +353,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr index 9fd1ac7ba84..615bd1df018 100644 --- a/tests/components/ring/snapshots/test_sensor.ambr +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -117,11 +120,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '123456-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -131,7 +134,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Downstairs Wi-Fi signal strength', + 'friendly_name': 'Downstairs Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -151,6 +154,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,6 +207,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -253,6 +258,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,104 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_door_last_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '987654-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_door_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '987654-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_door_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -301,6 +405,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -348,6 +453,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -395,6 +501,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,11 +519,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '987654-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -426,7 +533,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Door Wi-Fi signal strength', + 'friendly_name': 'Front Door Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -444,6 +551,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -485,6 +593,104 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_last_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '765432-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '765432-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -492,6 +698,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +746,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -556,11 +764,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '765432-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -570,7 +778,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Wi-Fi signal strength', + 'friendly_name': 'Front Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -590,6 +798,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -640,6 +849,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -687,6 +897,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -735,6 +946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -782,6 +994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -829,6 +1042,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -876,6 +1090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -893,11 +1108,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '185036587-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -907,7 +1122,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Ingress Wi-Fi signal strength', + 'friendly_name': 'Ingress Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -927,6 +1142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -977,6 +1193,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1235,104 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.internal_last_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.internal_last_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '345678-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last ding', + }), + 'context': , + 'entity_id': 'sensor.internal_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.internal_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '345678-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last motion', + }), + 'context': , + 'entity_id': 'sensor.internal_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.internal_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1025,6 +1340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1072,6 +1388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1089,11 +1406,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '345678-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -1103,7 +1420,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Internal Wi-Fi signal strength', + 'friendly_name': 'Internal Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , diff --git a/tests/components/ring/snapshots/test_siren.ambr b/tests/components/ring/snapshots/test_siren.ambr index c49ab2cb30f..8ef08815a1e 100644 --- a/tests/components/ring/snapshots/test_siren.ambr +++ b/tests/components/ring/snapshots/test_siren.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -63,6 +64,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_switch.ambr b/tests/components/ring/snapshots/test_switch.ambr index 57c27cfedfa..8c7c55d5169 100644 --- a/tests/components/ring/snapshots/test_switch.ambr +++ b/tests/components/ring/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 81d7d6e6687..c588b022265 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -18,7 +18,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_automation, setup_platform +from .common import ( + MockConfigEntry, + async_check_entity_translations, + setup_automation, + setup_platform, +) from .device_mocks import ( FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, @@ -67,6 +72,9 @@ async def test_states( ) -> None: """Test states.""" await setup_platform(hass, Platform.BINARY_SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, BINARY_SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 4b4f019fdf7..54638df9a46 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -436,9 +436,9 @@ async def test_camera_webrtc( assert response assert response.get("success") is False assert response["error"]["code"] == "home_assistant_error" - msg = "The sdp_m_line_index is required for ring webrtc streaming" - assert msg in response["error"].get("message") - assert msg in caplog.text + error_msg = f"Error negotiating stream for {front_camera_mock.name}" + assert error_msg in response["error"].get("message") + assert error_msg in caplog.text front_camera_mock.on_webrtc_candidate.assert_called_once() # Answer message diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index 409cdac55aa..778bad67d77 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -6,12 +6,12 @@ import pytest import ring_doorbell from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ring import DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import MOCK_HARDWARE_ID @@ -269,9 +269,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -302,9 +300,7 @@ async def test_dhcp_discovery( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=ip_address, macaddress=mac_address, hostname=hostname - ), + data=DhcpServiceInfo(ip=ip_address, macaddress=mac_address, hostname=hostname), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 27d4813f02d..66decb5ce15 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.ring.const import ( CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL, ) -from homeassistant.components.ring.coordinator import RingEventListener +from homeassistant.components.ring.coordinator import RingConfigEntry, RingEventListener from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -80,12 +80,12 @@ async def test_auth_failed_on_setup( ("error_type", "log_msg"), [ ( - RingTimeout, - "Timeout communicating with API: ", + RingTimeout("Some internal error info"), + "Timeout communicating with Ring API", ), ( - RingError, - "Error communicating with API: ", + RingError("Some internal error info"), + "Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -95,6 +95,7 @@ async def test_error_on_setup( mock_ring_client, mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, error_type, log_msg, ) -> None: @@ -166,11 +167,11 @@ async def test_auth_failure_on_device_update( [ ( RingTimeout, - "Error fetching devices data: Timeout communicating with API: ", + "Error fetching devices data: Timeout communicating with Ring API", ), ( RingError, - "Error fetching devices data: Error communicating with API: ", + "Error fetching devices data: Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -178,7 +179,7 @@ async def test_auth_failure_on_device_update( async def test_error_on_global_update( hass: HomeAssistant, mock_ring_client, - mock_config_entry: MockConfigEntry, + mock_config_entry: RingConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, error_type, @@ -189,15 +190,35 @@ async def test_error_on_global_update( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - mock_ring_client.async_update_devices.side_effect = error_type + coordinator = mock_config_entry.runtime_data.devices_coordinator + assert coordinator - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch.object( + coordinator, "_async_update_data", wraps=coordinator._async_update_data + ) as refresh_spy: + error = error_type("Some internal error info 1") + mock_ring_client.async_update_devices.side_effect = error - assert log_msg in caplog.text + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error + assert log_msg in caplog.text + + # Check log is not being spammed. + refresh_spy.reset_mock() + error2 = error_type("Some internal error info 2") + caplog.clear() + mock_ring_client.async_update_devices.side_effect = error2 + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error2 + assert log_msg not in caplog.text @pytest.mark.parametrize( @@ -205,11 +226,11 @@ async def test_error_on_global_update( [ ( RingTimeout, - "Error fetching devices data: Timeout communicating with API for device Front: ", + "Error fetching devices data: Timeout communicating with Ring API", ), ( RingError, - "Error fetching devices data: Error communicating with API for device Front: ", + "Error fetching devices data: Error communicating with Ring API", ), ], ids=["timeout-error", "other-error"], @@ -218,7 +239,7 @@ async def test_error_on_device_update( hass: HomeAssistant, mock_ring_client, mock_ring_devices, - mock_config_entry: MockConfigEntry, + mock_config_entry: RingConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, error_type, @@ -229,15 +250,36 @@ async def test_error_on_device_update( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - front_door_doorbell = mock_ring_devices.get_device(765432) - front_door_doorbell.async_history.side_effect = error_type + coordinator = mock_config_entry.runtime_data.devices_coordinator + assert coordinator - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + with patch.object( + coordinator, "_async_update_data", wraps=coordinator._async_update_data + ) as refresh_spy: + error = error_type("Some internal error info 1") + front_door_doorbell = mock_ring_devices.get_device(765432) + front_door_doorbell.async_history.side_effect = error - assert log_msg in caplog.text - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error + assert log_msg in caplog.text + + # Check log is not being spammed. + error2 = error_type("Some internal error info 2") + front_door_doorbell.async_history.side_effect = error2 + refresh_spy.reset_mock() + caplog.clear() + freezer.tick(SCAN_INTERVAL * 2) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + refresh_spy.assert_called() + assert coordinator.last_exception.__cause__ == error2 + assert log_msg not in caplog.text @pytest.mark.parametrize( @@ -444,6 +486,7 @@ async def test_no_listen_start( version=1, data={"username": "foo", "token": {}}, ) + mock_entry.add_to_hass(hass) # Create a binary sensor entity so it is not ignored by the deprecation check # and the listener will start entity_registry.async_get_or_create( @@ -457,7 +500,6 @@ async def test_no_listen_start( mock_ring_event_listener_class.return_value.started = False - mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ring/test_number.py b/tests/components/ring/test_number.py index aa484c6a7b2..9f1581742f2 100644 --- a/tests/components/ring/test_number.py +++ b/tests/components/ring/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from tests.common import snapshot_platform @@ -54,6 +54,9 @@ async def test_states( mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.NUMBER) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, NUMBER_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 48f679c4524..dcd3d5bddd6 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from .device_mocks import ( DOWNSTAIRS_DEVICE_ID, FRONT_DEVICE_ID, @@ -57,6 +57,10 @@ def create_deprecated_and_disabled_sensor_entities( create_entry("ingress", "doorbell_volume", INGRESS_DEVICE_ID) create_entry("ingress", "mic_volume", INGRESS_DEVICE_ID) create_entry("ingress", "voice_volume", INGRESS_DEVICE_ID) + for desc in ("last_motion", "last_ding"): + create_entry("front", desc, FRONT_DEVICE_ID) + create_entry("front_door", desc, FRONT_DOOR_DEVICE_ID) + create_entry("internal", desc, INTERNAL_DEVICE_ID) # Disabled for desc in ("wifi_signal_category", "wifi_signal_strength"): @@ -78,6 +82,9 @@ async def test_states( """Test states.""" mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 435e762a646..d4d7376a564 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -46,6 +46,7 @@ async def test_entity_id_migration( ) -> None: """Test the migration of unique IDs on config entry setup.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + config_entry.add_to_hass(hass) # Pre-create old style unique IDs charging = entity_registry.async_get_or_create( diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d65bf7c61d7..9b3a6633c62 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -2,7 +2,11 @@ from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +import pathlib +import shutil +from typing import Any +from unittest.mock import Mock, patch +import uuid import pytest from roborock import RoborockCategory, RoomMapping @@ -15,9 +19,9 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .mock_data import ( BASE_URL, @@ -26,6 +30,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + SCENES, USER_DATA, USER_EMAIL, ) @@ -63,15 +68,30 @@ class A01Mock(RoborockMqttClientA01): return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols} +@pytest.fixture(name="bypass_api_client_fixture") +def bypass_api_client_fixture() -> None: + """Skip calls to the API client.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=HOME_DATA, + ), + patch( + "homeassistant.components.roborock.RoborockApiClient.get_scenes", + return_value=SCENES, + ), + ): + yield + + @pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture() -> None: +def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=HOME_DATA, + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", @@ -139,6 +159,22 @@ def bypass_api_fixture() -> None: yield +@pytest.fixture(name="send_message_side_effect") +def send_message_side_effect_fixture() -> Any: + """Fixture to return a side effect for the send_message method.""" + return None + + +@pytest.fixture(name="mock_send_message") +def mock_send_message_fixture(send_message_side_effect: Any) -> Mock: + """Fixture to mock the send_message method.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", + side_effect=send_message_side_effect, + ) as mock_send_message: + yield mock_send_message + + @pytest.fixture def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: """Bypass api for tests that require only having v1 devices.""" @@ -179,10 +215,31 @@ async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" with patch("homeassistant.components.roborock.PLATFORMS", platforms): - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() yield mock_roborock_entry + + +@pytest.fixture +async def cleanup_map_storage( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> Generator[pathlib.Path]: + """Test cleanup, remove any map storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch( + "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path + ): + storage_path = ( + pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id + ) + yield storage_path + # We need to first unload the config entry because unloading it will + # persist any unsaved maps to storage. + if mock_roborock_entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 6e3fb229aa9..59c54892687 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,6 +9,7 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, + HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1150,3 +1151,19 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) + + +SCENES = [ + HomeDataScene.from_dict( + { + "name": "sc1", + "id": 12, + }, + ), + HomeDataScene.from_dict( + { + "name": "sc2", + "id": 24, + }, + ), +] diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index e240dccf7eb..fd6c8b2796a 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,13 +3,17 @@ import copy from datetime import timedelta from http import HTTPStatus +import io from unittest.mock import patch +from PIL import Image import pytest from roborock import RoborockException +from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -32,22 +36,27 @@ async def test_floorplan_image( hass_client: ClientSessionGenerator, ) -> None: """Test floor plan map image is correctly set up.""" - # Setup calls the image parsing the first time and caches it. assert len(hass.states.async_all("image")) == 4 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - # call a second time -should return cached data + # Load the image on demand client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - # Call a third time - this time forcing it to update - now = dt_util.utcnow() + timedelta(seconds=91) + assert body[0:4] == b"\x89PNG" + + # Call a second time - this time forcing it to update - and save new image + now = dt_util.utcnow() + timedelta(minutes=61) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) with ( patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -56,6 +65,10 @@ async def test_floorplan_image( patch( "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=new_map_data, + ) as parse_map, ): async_fire_time_changed(hass, now) await hass.async_block_till_done() @@ -63,6 +76,7 @@ async def test_floorplan_image( assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None + assert parse_map.call_count == 1 async def test_floorplan_image_failed_parse( @@ -97,13 +111,104 @@ async def test_floorplan_image_failed_parse( assert not resp.ok +async def test_load_stored_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, +) -> None: + """Test that we correctly load an image from storage when it already exists.""" + img_byte_arr = io.BytesIO() + MAP_DATA.image.data.save(img_byte_arr, format="PNG") + img_bytes = img_byte_arr.getvalue() + + # Load the image on demand, which should queue it to be cached on disk + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map: + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == img_bytes + + # Ensure that we never tried to update the map, and only used the cached image. + assert parse_map.call_count == 0 + + +async def test_fail_to_save_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle a oserror on saving an image.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + + with patch( + "homeassistant.components.roborock.roborock_storage.Path.write_bytes", + side_effect=OSError, + ): + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + assert "Unable to write map file" in caplog.text + + # Config entry is unloaded successfully + assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_fail_to_load_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to load an image.""" + with ( + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map, + patch( + "homeassistant.components.roborock.roborock_storage.Path.exists", + return_value=True, + ), + patch( + "homeassistant.components.roborock.roborock_storage.Path.read_bytes", + side_effect=OSError, + ) as read_bytes, + ): + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert read_bytes.call_count == 4 + # Ensure that we never updated the map manually since we couldn't load it. + assert parse_map.call_count == 0 + assert "Unable to read map file" in caplog.text + + async def test_fail_parse_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, ) -> None: - """Test that if we fail parsing on startup, we create the entity but set it as unavailable.""" + """Test that if we fail parsing on startup, we still create the entity.""" map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( @@ -115,7 +220,28 @@ async def test_fail_parse_on_startup( assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None - assert image_entity.state == STATE_UNAVAILABLE + assert image_entity.state + + +async def test_fail_get_map_on_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, +) -> None: + """Test that if we fail getting map on startup, we can still create the entity.""" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=None, + ), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") + ) is not None + assert image_entity.state async def test_fail_updating_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index f4f490e68d9..904a3af89d6 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,8 @@ """Test for Roborock init.""" from copy import deepcopy +from http import HTTPStatus +import pathlib from unittest.mock import patch import pytest @@ -13,12 +15,14 @@ from roborock import ( from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .mock_data import HOME_DATA from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_unload_entry( @@ -163,6 +167,68 @@ async def test_reauth_started( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_remove_from_hass( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + cleanup_map_storage: pathlib.Path, +) -> None: + """Test that removing from hass removes any existing images.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert not cleanup_map_storage.exists() + + # Flush to disk + await hass.config_entries.async_unload(setup_entry.entry_id) + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + await hass.config_entries.async_remove(setup_entry.entry_id) + # After removal, directories should be empty. + assert not cleanup_map_storage.exists() + + +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_oserror_remove_image( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to remove an image.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + # Image content is saved when unloading + assert not cleanup_map_storage.exists() + await hass.config_entries.async_unload(setup_entry.entry_id) + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + with patch( + "homeassistant.components.roborock.roborock_storage.shutil.rmtree", + side_effect=OSError, + ): + await hass.config_entries.async_remove(setup_entry.entry_id) + assert "Unable to remove map files" in caplog.text + + async def test_not_supported_protocol( hass: HomeAssistant, bypass_api_fixture, diff --git a/tests/components/roborock/test_scene.py b/tests/components/roborock/test_scene.py new file mode 100644 index 00000000000..15707784feb --- /dev/null +++ b/tests/components/roborock/test_scene.py @@ -0,0 +1,112 @@ +"""Test Roborock Scene platform.""" + +from unittest.mock import ANY, patch + +import pytest +from roborock import RoborockException + +from homeassistant.const import SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: + """Fixture to raise when getting scenes.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_scenes", + side_effect=RoborockException(), + ), + ): + yield + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("scene.roborock_s7_maxv_sc1"), + ("scene.roborock_s7_maxv_sc2"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_scenes_failure( + hass: HomeAssistant, + bypass_api_client_get_scenes_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that if scene retrieval fails, no entity is being created.""" + # Ensure that the entity does not exist + assert hass.states.get(entity_id) is None + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to set platforms used in the test.""" + return [Platform.SCENE] + + +@pytest.mark.parametrize( + ("entity_id", "scene_id"), + [ + ("scene.roborock_s7_maxv_sc1", 12), + ("scene.roborock_s7_maxv_sc2", 24), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_execute_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + scene_id: int, +) -> None: + """Test activating the scene entities.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene" + ) as mock_execute_scene: + await hass.services.async_call( + "scene", + SERVICE_TURN_ON, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, scene_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id", "scene_id"), + [ + ("scene.roborock_s7_maxv_sc1", 12), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_execute_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + scene_id: int, +) -> None: + """Test failure while activating the scene entity.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene", + side_effect=RoborockException, + ) as mock_execute_scene, + pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), + ): + await hass.services.async_call( + "scene", + SERVICE_TURN_ON, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, scene_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 2476bfe497c..e2df9a3498f 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -1,6 +1,6 @@ """Test Roborock Switch platform.""" -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,27 +37,22 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + mock_send_message.reset_mock() + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -67,8 +63,12 @@ async def test_update_success( ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failed( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -78,10 +78,6 @@ async def test_update_failed( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), ): await hass.services.async_call( diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index eb48e8e537f..9c0a53893ed 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -1,7 +1,7 @@ """Test Roborock Time platform.""" from datetime import time -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,16 +37,13 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "time", - SERVICE_SET_VALUE, - service_data={"time": time(hour=1, minute=1)}, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -55,8 +53,12 @@ async def test_update_success( ("time.roborock_s7_maxv_do_not_disturb_begin"), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failure( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -64,13 +66,7 @@ async def test_update_failure( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, - pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), - ): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "time", SERVICE_SET_VALUE, diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index fe3ef215524..3165e5c4ba0 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -2,8 +2,15 @@ from ipaddress import ip_address -from homeassistant.components import ssdp, zeroconf -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) NAME = "Roku 3" NAME_ROKUTV = '58" Onn Roku TV' @@ -13,7 +20,7 @@ SSDP_LOCATION = "http://192.168.1.160/" UPNP_FRIENDLY_NAME = "My Roku 3" UPNP_SERIAL = "1GU48T017973" -MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -25,14 +32,14 @@ MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( HOMEKIT_HOST = "192.168.1.161" -MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_HOMEKIT_DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address(HOMEKIT_HOST), ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, properties={ - zeroconf.ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", + ATTR_PROPERTIES_ID: "2d:97:da:ee:dc:99", }, type="mock_type", ) diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index 78cd65250f8..a79a23782ce 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -22,7 +22,7 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py index a29f899ee9d..55d54f3a80b 100644 --- a/tests/components/romy/test_config_flow.py +++ b/tests/components/romy/test_config_flow.py @@ -6,11 +6,14 @@ from unittest.mock import Mock, PropertyMock, patch from romy import RomyRobot from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.romy.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) def _create_mocked_romy( @@ -164,14 +167,14 @@ async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None assert result2["type"] is FlowResultType.CREATE_ENTRY -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=8080, hostname="aicu-aicgsbksisfapcjqmqjq.local", type="mock_type", name="myROMY", - properties={zeroconf.ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, + properties={ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, ) diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index dedccc14249..5b6766f7eb9 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest from roombapy import RoombaConnectionError, RoombaInfo -from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import ( CONF_BLID, @@ -23,6 +22,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -32,7 +33,7 @@ VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password" DISCOVERY_DEVICES = [ ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="501479ddeeff", hostname="irobot-blid", @@ -40,7 +41,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=MOCK_IP, macaddress="80a589ddeeff", hostname="roomba-blid", @@ -48,7 +49,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", @@ -60,7 +61,7 @@ DISCOVERY_DEVICES = [ ), ( SOURCE_ZEROCONF, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(MOCK_IP), ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", @@ -74,12 +75,12 @@ DISCOVERY_DEVICES = [ DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="4.4.4.4", macaddress="50:14:79:DD:EE:FF", hostname="irobot-blid", ), - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip="5.5.5.5", macaddress="80:A5:89:DD:EE:FF", hostname="roomba-blid", @@ -692,7 +693,7 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( @pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( hass: HomeAssistant, - discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], + discovery_data: tuple[str, DhcpServiceInfo | ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -910,7 +911,7 @@ async def test_dhcp_discovery_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -933,7 +934,7 @@ async def test_dhcp_discovery_already_configured_host(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -959,7 +960,7 @@ async def test_dhcp_discovery_already_configured_blid(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -985,7 +986,7 @@ async def test_dhcp_discovery_not_irobot(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="Notirobot-blid", @@ -1006,7 +1007,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blid", @@ -1023,7 +1024,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", @@ -1044,7 +1045,7 @@ async def test_dhcp_discovery_partial_hostname(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-bl", @@ -1082,7 +1083,7 @@ async def test_dhcp_discovery_when_user_flow_in_progress(hass: HomeAssistant) -> result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=MOCK_IP, macaddress="aabbccddeeff", hostname="irobot-blidthatislonger", diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 5e607e6a8df..8eb77006061 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/rova/snapshots/test_sensor.ambr b/tests/components/rova/snapshots/test_sensor.ambr index 866f1c735c1..90cf29a1b89 100644 --- a/tests/components/rova/snapshots/test_sensor.ambr +++ b/tests/components/rova/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index bf6884e09fb..2516bd81650 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -78,6 +78,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.mute = AsyncMock() zone.unmute = AsyncMock() zone.toggle_mute = AsyncMock() + zone.set_seek_time = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index c92f06c4bc0..e3185a06b24 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.20.75', 'connections': set({ tuple( diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 51cbb9772dc..550ea9404df 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import AsyncMock from homeassistant.components.russound_rio.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import MOCK_CONFIG, MOCK_RECONFIGURATION_CONFIG, MODEL diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 5a6420da000..d0c18a9b1e7 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, @@ -16,6 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, @@ -232,3 +234,22 @@ async def test_power_service( await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, blocking=True) mock_russound_client.controllers[1].zones[1].zone_off.assert_called_once() + + +async def test_media_seek( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test media seek service.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, ATTR_MEDIA_SEEK_POSITION: 100}, + ) + + mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( + 100 + ) diff --git a/tests/components/ruuvi_gateway/test_config_flow.py b/tests/components/ruuvi_gateway/test_config_flow.py index c4ecf929f94..14f74a7add7 100644 --- a/tests/components/ruuvi_gateway/test_config_flow.py +++ b/tests/components/ruuvi_gateway/test_config_flow.py @@ -6,10 +6,10 @@ from aioruuvigateway.excs import CannotConnect, InvalidAuth import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.ruuvi_gateway.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .consts import ( BASE_DATA, @@ -32,7 +32,7 @@ DHCP_DATA = {**BASE_DATA, "host": DHCP_IP} BASE_DATA, ), ( - dhcp.DhcpServiceInfo( + DhcpServiceInfo( hostname="RuuviGateway1234", ip=DHCP_IP, macaddress="1234567890ab", diff --git a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr index 9f3087df3d1..1feaece1c3e 100644 --- a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sabnzbd/snapshots/test_button.ambr b/tests/components/sabnzbd/snapshots/test_button.ambr index 9b965e10518..f09bb44e8e4 100644 --- a/tests/components/sabnzbd/snapshots/test_button.ambr +++ b/tests/components/sabnzbd/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sabnzbd/snapshots/test_number.ambr b/tests/components/sabnzbd/snapshots/test_number.ambr index 6a370797264..623002470b7 100644 --- a/tests/components/sabnzbd/snapshots/test_number.ambr +++ b/tests/components/sabnzbd/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sabnzbd/snapshots/test_sensor.ambr b/tests/components/sabnzbd/snapshots/test_sensor.ambr index 8b977e69aa6..893d270a569 100644 --- a/tests/components/sabnzbd/snapshots/test_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -272,6 +277,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -323,6 +329,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,6 +382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -478,6 +487,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index ec12031ef96..105ef0f25ad 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -21,7 +21,7 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 1a7347ff0ce..c1a9da4e284 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -2,18 +2,11 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT -from homeassistant.components import ssdp from homeassistant.components.samsungtv.const import ( CONF_SESSION_ID, METHOD_LEGACY, METHOD_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, -) from homeassistant.const import ( CONF_HOST, CONF_IP_ADDRESS, @@ -24,6 +17,13 @@ from homeassistant.const import ( CONF_PORT, CONF_TOKEN, ) +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) MOCK_CONFIG = { CONF_HOST: "fake_host", @@ -61,7 +61,7 @@ MOCK_ENTRY_WS_WITH_MAC = { CONF_TOKEN: "123456789", } -MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_RENDERING_CONTROL_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:schemas-upnp-org:service:RenderingControl:1", ssdp_location="https://fake_host:12345/test", @@ -72,7 +72,7 @@ MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_MAIN_TV_AGENT_ST = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="urn:samsung.com:service:MainTVAgent2:1", ssdp_location="https://fake_host:12345/tv_agent", diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 017a2bc3e60..ad01b5454ff 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -46,6 +47,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -115,6 +117,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index eb78332b7b3..576a5f6d534 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -17,7 +17,6 @@ from websockets import frames from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.samsungtv.config_flow import SamsungTVConfigFlow from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, @@ -33,13 +32,6 @@ from homeassistant.components.samsungtv.const import ( TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) -from homeassistant.components.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_UDN, - SsdpServiceInfo, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -54,6 +46,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from .const import ( @@ -83,7 +84,7 @@ MOCK_IMPORT_WSDATA = { CONF_PORT: 8002, } MOCK_USER_DATA = {CONF_HOST: "fake_host", CONF_NAME: "fake_name"} -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -94,7 +95,7 @@ MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172de", }, ) -MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NO_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="https://fake_host:12345/test", @@ -104,7 +105,7 @@ MOCK_SSDP_DATA_NO_MANUFACTURER = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_NOPREFIX = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -115,7 +116,7 @@ MOCK_SSDP_DATA_NOPREFIX = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( +MOCK_SSDP_DATA_WRONGMODEL = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://fake2_host:12345/test", @@ -126,11 +127,11 @@ MOCK_SSDP_DATA_WRONGMODEL = ssdp.SsdpServiceInfo( ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", }, ) -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="fake_host", macaddress="aabbccddeeff", hostname="fake_hostname" ) EXISTING_IP = "192.168.40.221" -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", @@ -1704,7 +1705,7 @@ async def test_update_legacy_missing_mac_from_dhcp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) @@ -1741,7 +1742,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=EXISTING_IP, macaddress="aabbccddeeff", hostname="fake_hostname" ), ) diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 0319d5dd8dd..e8e0b699a7e 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -51,6 +51,7 @@ async def test_entry_diagnostics( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -91,6 +92,7 @@ async def test_entry_diagnostics_encrypted( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -130,6 +132,7 @@ async def test_entry_diagnostics_encrypte_offline( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 1a7c8713b17..3d9633bbf96 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -78,7 +78,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( diff --git a/tests/components/sanix/snapshots/test_sensor.ambr b/tests/components/sanix/snapshots/test_sensor.ambr index 84c97ce68b1..6cf0254b66b 100644 --- a/tests/components/sanix/snapshots/test_sensor.ambr +++ b/tests/components/sanix/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -251,6 +256,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/schedule/snapshots/test_init.ambr b/tests/components/schedule/snapshots/test_init.ambr new file mode 100644 index 00000000000..93cde4f5733 --- /dev/null +++ b/tests/components/schedule/snapshots/test_init.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_service_get[schedule.from_storage-get-after-update] + dict({ + 'friday': list([ + ]), + 'monday': list([ + ]), + 'saturday': list([ + ]), + 'sunday': list([ + ]), + 'thursday': list([ + ]), + 'tuesday': list([ + ]), + 'wednesday': list([ + dict({ + 'from': datetime.time(17, 0), + 'to': datetime.time(19, 0), + }), + ]), + }) +# --- +# name: test_service_get[schedule.from_storage-get] + dict({ + 'friday': list([ + dict({ + 'data': dict({ + 'party_level': 'epic', + }), + 'from': datetime.time(17, 0), + 'to': datetime.time(23, 59, 59), + }), + ]), + 'monday': list([ + ]), + 'saturday': list([ + dict({ + 'from': datetime.time(0, 0), + 'to': datetime.time(23, 59, 59), + }), + ]), + 'sunday': list([ + dict({ + 'data': dict({ + 'entry': 'VIPs only', + }), + 'from': datetime.time(0, 0), + 'to': datetime.time(23, 59, 59, 999999), + }), + ]), + 'thursday': list([ + ]), + 'tuesday': list([ + ]), + 'wednesday': list([ + ]), + }) +# --- diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index 18346122bfd..fef2ff745cd 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -8,10 +8,12 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR from homeassistant.components.schedule.const import ( ATTR_NEXT_EVENT, + CONF_ALL_DAYS, CONF_DATA, CONF_FRIDAY, CONF_FROM, @@ -23,12 +25,14 @@ from homeassistant.components.schedule.const import ( CONF_TUESDAY, CONF_WEDNESDAY, DOMAIN, + SERVICE_GET, ) from homeassistant.const import ( ATTR_EDITABLE, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME, + CONF_ENTITY_ID, CONF_ICON, CONF_ID, CONF_NAME, @@ -754,3 +758,66 @@ async def test_ws_create( assert result["party_mode"][CONF_MONDAY] == [ {CONF_FROM: "12:00:00", CONF_TO: saved_to} ] + + +async def test_service_get( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + schedule_setup: Callable[..., Coroutine[Any, Any, bool]], +) -> None: + """Test getting a single schedule via service.""" + assert await schedule_setup() + + entity_id = "schedule.from_storage" + + # Test retrieving a single schedule via service call + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET, + { + CONF_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + result = service_result.get(entity_id) + + assert set(result) == CONF_ALL_DAYS + assert result == snapshot(name=f"{entity_id}-get") + + # Now we update the schedule via WS + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": entity_id.rsplit(".", maxsplit=1)[-1], + CONF_NAME: "Party pooper", + CONF_ICON: "mdi:party-pooper", + CONF_MONDAY: [], + CONF_TUESDAY: [], + CONF_WEDNESDAY: [{CONF_FROM: "17:00:00", CONF_TO: "19:00:00"}], + CONF_THURSDAY: [], + CONF_FRIDAY: [], + CONF_SATURDAY: [], + CONF_SUNDAY: [], + } + ) + resp = await client.receive_json() + assert resp["success"] + + # Test retrieving the schedule via service call after WS update + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET, + { + CONF_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + result = service_result.get(entity_id) + + assert set(result) == CONF_ALL_DAYS + assert result == snapshot(name=f"{entity_id}-get-after-update") diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index c7049443ab7..a7f94b80038 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 57a139e582e..97da66c7e93 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.schlage.const import DOMAIN, UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry from . import MockSchlageConfigEntry diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 237d3eab257..c7db7a33959 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Pentair: DD-EE-FF', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index 8ca6bd4cb90..ad8ef125dac 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -12,7 +12,6 @@ from screenlogicpy.const.common import ( ) from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, @@ -25,6 +24,7 @@ from homeassistant.components.screenlogic.const import ( from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -86,6 +86,53 @@ async def test_flow_discover_none(hass: HomeAssistant) -> None: assert result["step_id"] == "gateway_entry" +async def test_flow_replace_ignored(hass: HomeAssistant) -> None: + """Test we can replace ignored entries.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:c0:33:01:01:01", + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.screenlogic.config_flow.discovery.async_discover", + return_value=[ + { + SL_GATEWAY_IP: "1.1.1.1", + SL_GATEWAY_PORT: 80, + SL_GATEWAY_TYPE: 12, + SL_GATEWAY_SUBTYPE: 2, + SL_GATEWAY_NAME: "Pentair: 01-01-01", + }, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "gateway_select" + + with patch( + "homeassistant.components.screenlogic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={GATEWAY_SELECT_KEY: "00:c0:33:01:01:01"} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pentair: 01-01-01" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_flow_discover_error(hass: HomeAssistant) -> None: """Test when discovery errors.""" @@ -135,7 +182,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Pentair: 01-01-01", ip="1.1.1.1", macaddress="aabbccddeeff", diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 7f03a89c548..f65e5483ae4 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, template from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import MockConfigEntry, async_mock_service @@ -37,7 +37,7 @@ def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]: return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=BLUEPRINT_SCHEMA, diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index a5eda3757a9..3b0bff7e82e 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -42,8 +42,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.util import yaml -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, @@ -1722,7 +1721,7 @@ async def test_blueprint_script_fails_substitution( """Test blueprint script with bad inputs.""" with patch( "homeassistant.components.blueprint.models.BlueprintInputs.async_substitute", - side_effect=yaml.UndefinedSubstitution("blah"), + side_effect=yaml_util.UndefinedSubstitution("blah"), ): assert await async_setup_component( hass, diff --git a/tests/components/sense/snapshots/test_binary_sensor.ambr b/tests/components/sense/snapshots/test_binary_sensor.ambr index 339830b16d3..7221a0bc518 100644 --- a/tests/components/sense/snapshots/test_binary_sensor.ambr +++ b/tests/components/sense/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sense/snapshots/test_sensor.ambr b/tests/components/sense/snapshots/test_sensor.ambr index 4a3507880a1..0a68553cf04 100644 --- a/tests/components/sense/snapshots/test_sensor.ambr +++ b/tests/components/sense/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -120,6 +122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -229,6 +233,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -285,6 +290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -341,6 +347,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -397,6 +404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -453,6 +461,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -509,6 +518,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -562,6 +572,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -618,6 +629,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -674,6 +686,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -727,6 +740,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -780,6 +794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -831,6 +846,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -881,6 +897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -932,6 +949,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -982,6 +1000,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1035,6 +1054,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1088,6 +1108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1141,6 +1162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1192,6 +1214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1242,6 +1265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1293,6 +1317,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1343,6 +1368,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1396,6 +1422,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1448,6 +1475,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1500,6 +1528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1552,6 +1581,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1605,6 +1635,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1658,6 +1689,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1709,6 +1741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1759,6 +1792,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1810,6 +1844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1860,6 +1895,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1913,6 +1949,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1965,6 +2002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2018,6 +2056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2071,6 +2110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2122,6 +2162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2172,6 +2213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2223,6 +2265,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2273,6 +2316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2326,6 +2370,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2379,6 +2424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2432,6 +2478,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2483,6 +2530,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2533,6 +2581,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2584,6 +2633,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2634,6 +2684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_binary_sensor.ambr b/tests/components/sensibo/snapshots/test_binary_sensor.ambr index 110a6ae8174..2e62c73acb4 100644 --- a/tests/components/sensibo/snapshots/test_binary_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -381,6 +389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,6 +485,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -522,6 +533,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +581,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -616,6 +629,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +677,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_button.ambr b/tests/components/sensibo/snapshots/test_button.ambr index 7ef6d56c714..6bfc4a5a44f 100644 --- a/tests/components/sensibo/snapshots/test_button.ambr +++ b/tests/components/sensibo/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index 5bcfae0917e..e3bd456ad23 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -94,6 +95,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -185,6 +187,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index 23ead2f6d96..80ee847cb55 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'hallway', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -38,6 +39,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -72,6 +74,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'bedroom', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -106,6 +109,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ }), diff --git a/tests/components/sensibo/snapshots/test_number.ambr b/tests/components/sensibo/snapshots/test_number.ambr index b632b95f1be..458c7ca7183 100644 --- a/tests/components/sensibo/snapshots/test_number.ambr +++ b/tests/components/sensibo/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +243,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +301,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr index 7438fb70140..05582a1ea16 100644 --- a/tests/components/sensibo/snapshots/test_select.ambr +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_sensor.ambr b/tests/components/sensibo/snapshots/test_sensor.ambr index 31e579d9929..bfd5f2d3e9a 100644 --- a/tests/components/sensibo/snapshots/test_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -321,6 +327,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -370,6 +377,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -421,6 +429,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,6 +481,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +533,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -623,6 +635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +685,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -725,6 +739,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -777,6 +792,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_switch.ambr b/tests/components/sensibo/snapshots/test_switch.ambr index 13cb73cef7a..e0ea140eb37 100644 --- a/tests/components/sensibo/snapshots/test_switch.ambr +++ b/tests/components/sensibo/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_update.ambr b/tests/components/sensibo/snapshots/test_update.ambr index 3eb69c9a812..c113d5615b1 100644 --- a/tests/components/sensibo/snapshots/test_update.ambr +++ b/tests/components/sensibo/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index d6176003582..7e848f3870c 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -954,7 +954,7 @@ async def test_climate_climate_react( "light": "on", }, "lowTemperatureThreshold": 5.5, - "type": "temperature", + "type": "feelsLike", }, } @@ -985,7 +985,7 @@ async def test_climate_climate_react( "horizontalSwing": "stopped", "light": "on", }, - ATTR_SMART_TYPE: "temperature", + ATTR_SMART_TYPE: "feelslike", }, blocking=True, ) @@ -993,7 +993,7 @@ async def test_climate_climate_react( mock_client.async_get_devices_data.return_value.parsed["ABC999111"].smart_on = True mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" - ].smart_type = "temperature" + ].smart_type = "feelsLike" mock_client.async_get_devices_data.return_value.parsed[ "ABC999111" ].smart_low_temp_threshold = 5.5 @@ -1038,7 +1038,7 @@ async def test_climate_climate_react( hass.states.get("sensor.hallway_climate_react_high_temperature_threshold").state == "30.5" ) - assert hass.states.get("sensor.hallway_climate_react_type").state == "temperature" + assert hass.states.get("sensor.hallway_climate_react_type").state == "feelslike" @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index 6cb8e6fe923..2d56fc4c51c 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData +import pytest from homeassistant.components.climate import HVACMode from homeassistant.components.sensibo.const import DOMAIN @@ -25,6 +26,7 @@ async def test_coordinator( mock_client: MagicMock, get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( @@ -87,3 +89,5 @@ async def test_coordinator( mock_data.assert_called_once() state = hass.states.get("climate.hallway") assert state.state == STATE_UNAVAILABLE + + assert "Platform sensibo does not generate unique IDs" not in caplog.text diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 78eee6ceba0..b4911983fe7 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -2,8 +2,14 @@ from __future__ import annotations +from datetime import timedelta +from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pysensibo.model import SensiboData +import pytest + from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.util import NoUsernameError from homeassistant.config_entries import SOURCE_USER, ConfigEntry, ConfigEntryState @@ -13,7 +19,7 @@ from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -103,3 +109,73 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, load_int.entry_id) assert response["success"] + + +@pytest.mark.parametrize( + ("entity_id", "device_ids"), + [ + # Device is ABC999111 + ("climate.hallway", ["ABC999111"]), + ("binary_sensor.hallway_filter_clean_required", ["ABC999111"]), + ("number.hallway_temperature_calibration", ["ABC999111"]), + ("sensor.hallway_filter_last_reset", ["ABC999111"]), + ("update.hallway_firmware", ["ABC999111"]), + # Device is AABBCC belonging to device ABC999111 + ("binary_sensor.hallway_motion_sensor_motion", ["ABC999111", "AABBCC"]), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_device_addition_and_removal( + hass: HomeAssistant, + load_int: ConfigEntry, + mock_client: MagicMock, + get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + entity_id: str, + device_ids: list[str], +) -> None: + """Test for automatic device addition and removal.""" + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Remove one of the devices + new_device_list = [ + device for device in get_data[2]["result"] if device["id"] != device_ids[0] + ] + mock_client.async_get_devices.return_value = { + "status": "success", + "result": new_device_list, + } + new_data = {k: v for k, v in get_data[0].parsed.items() if k != device_ids[0]} + new_raw = mock_client.async_get_devices.return_value["result"] + mock_client.async_get_devices_data.return_value = SensiboData(new_raw, new_data) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert not state + assert not entity_registry.async_get(entity_id) + for device_id in device_ids: + assert not device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the device back + mock_client.async_get_devices.return_value = get_data[2] + mock_client.async_get_devices_data.return_value = get_data[0] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + for device_id in device_ids: + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index c93eff92f3a..75dbdc88840 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -16,7 +16,7 @@ from homeassistant.components.select import ( ) from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -63,7 +63,7 @@ async def test_select_set_option( """Test the Sensibo select service.""" mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" + "AAZZAAZZ" ].active_features = [ "timestamp", "on", @@ -97,13 +97,11 @@ async def test_select_set_option( assert state.state == "on" mock_client.async_get_devices_data.return_value.parsed[ - "ABC999111" + "AAZZAAZZ" ].active_features = [ "timestamp", "on", "mode", - "targetTemperature", - "horizontalSwing", "light", ] @@ -142,6 +140,21 @@ async def test_select_set_option( state = hass.states.get("select.kitchen_light") assert state.state == "dim" + mock_client.async_get_devices_data.return_value.parsed[ + "AAZZAAZZ" + ].active_features = [ + "timestamp", + "on", + "mode", + ] + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.kitchen_light") + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize( "load_platforms", diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f50e92bc9df..f35c9520f71 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import load_json from .common import UNITS_OF_MEASUREMENT, MockSensor diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 0ea46a41273..b162200f95e 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -570,7 +570,7 @@ async def test_unit_translation_key_without_platform_raises( match="cannot have a translation key for unit of measurement before " "being added to the entity platform", ): - unit = entity0.unit_of_measurement # noqa: F841 + unit = entity0.unit_of_measurement setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) @@ -580,7 +580,7 @@ async def test_unit_translation_key_without_platform_raises( await hass.async_block_till_done() # Should not raise after being added to the platform - unit = entity0.unit_of_measurement # noqa: F841 + unit = entity0.unit_of_measurement assert unit == "Tests" @@ -2144,7 +2144,7 @@ async def test_non_numeric_validation_raise( (13, "13"), (17.50, "17.5"), ("1e-05", "1e-05"), - (Decimal(18.50), "18.5"), + (Decimal("18.50"), "18.50"), ("19.70", "19.70"), (None, STATE_UNKNOWN), ], @@ -2584,7 +2584,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 636fb9871c9..a5b6a07dde5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -40,7 +40,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import MockSensor @@ -57,7 +57,7 @@ from tests.components.recorder.common import ( ) from tests.typing import ( MockHAClientWebSocket, - RecorderInstanceGenerator, + RecorderInstanceContextManager, WebSocketGenerator, ) @@ -102,7 +102,7 @@ KW_SENSOR_ATTRIBUTES = { @pytest.fixture async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: """Set up recorder patches.""" @@ -121,15 +121,6 @@ def disable_mariadb_issue() -> None: yield -@pytest.fixture(autouse=True) -def disable_sqlite_issue() -> None: - """Disable creating issue about outdated SQLite version.""" - with patch( - "homeassistant.components.recorder.util._async_create_issue_deprecated_version" - ): - yield - - async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, @@ -5458,12 +5449,11 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: assert ATTR_FRIENDLY_NAME in states[0].attributes +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ - "component.test.issues..title", - "component.test.issues..description", "component.sensor.issues..title", "component.sensor.issues..description", ] diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 43e18b89e72..fd28a7052a5 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -17,14 +17,14 @@ from homeassistant.components.recorder.util import session_scope from homeassistant.core import CoreState from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_test_home_assistant from tests.components.recorder.common import ( async_wait_recording_done, do_adhoc_statistics, ) -from tests.typing import RecorderInstanceGenerator +from tests.typing import RecorderInstanceContextManager POWER_SENSOR_ATTRIBUTES = { "device_class": "energy", @@ -47,7 +47,7 @@ def disable_db_issue_creation(): @pytest.mark.parametrize("enable_missing_statistics", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_compile_missing_statistics( - async_test_recorder: RecorderInstanceGenerator, freezer: FrozenDateTimeFactory + async_test_recorder: RecorderInstanceContextManager, freezer: FrozenDateTimeFactory ) -> None: """Test compile missing statistics.""" three_days_ago = datetime(2021, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/sensorpush/test_config_flow.py b/tests/components/sensorpush/test_config_flow.py index 7e87dd1c6b8..194f4fc4a78 100644 --- a/tests/components/sensorpush/test_config_flow.py +++ b/tests/components/sensorpush/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HTW_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + data={}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.sensorpush.config_flow.async_discovered_service_info", + return_value=[HTW_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.sensorpush.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "61DE521B-F0BF-9F44-64D4-75BBE1738105"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "HT.w 0CA1" + assert result2["data"] == {} + assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/sensorpush_cloud/__init__.py b/tests/components/sensorpush_cloud/__init__.py new file mode 100644 index 00000000000..2a5d148692c --- /dev/null +++ b/tests/components/sensorpush_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the SensorPush Cloud integration.""" diff --git a/tests/components/sensorpush_cloud/conftest.py b/tests/components/sensorpush_cloud/conftest.py new file mode 100644 index 00000000000..ac434b04353 --- /dev/null +++ b/tests/components/sensorpush_cloud/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the SensorPush Cloud tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from sensorpush_ha import SensorPushCloudApi + +from homeassistant.components.sensorpush_cloud.const import DOMAIN +from homeassistant.const import CONF_EMAIL + +from .const import CONF_DATA, MOCK_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_api() -> Generator[AsyncMock]: + """Override SensorPushCloudApi.""" + mock_api = AsyncMock(SensorPushCloudApi) + with ( + patch( + "homeassistant.components.sensorpush_cloud.config_flow.SensorPushCloudApi", + return_value=mock_api, + ), + ): + yield mock_api + + +@pytest.fixture +def mock_helper() -> Generator[AsyncMock]: + """Override SensorPushCloudHelper.""" + with ( + patch( + "homeassistant.components.sensorpush_cloud.coordinator.SensorPushCloudHelper", + autospec=True, + ) as mock_helper, + ): + helper = mock_helper.return_value + helper.async_get_data.return_value = MOCK_DATA + yield helper + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """ConfigEntry mock.""" + return MockConfigEntry( + domain=DOMAIN, data=CONF_DATA, unique_id=CONF_DATA[CONF_EMAIL] + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sensorpush_cloud.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sensorpush_cloud/const.py b/tests/components/sensorpush_cloud/const.py new file mode 100644 index 00000000000..1efc4ea445a --- /dev/null +++ b/tests/components/sensorpush_cloud/const.py @@ -0,0 +1,32 @@ +"""Constants for the SensorPush Cloud tests.""" + +from sensorpush_ha import SensorPushCloudData + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.util import dt as dt_util + +CONF_DATA = { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", +} + +NUM_MOCK_DEVICES = 3 + +MOCK_DATA = { + f"test-sensor-device-id-{i}": SensorPushCloudData( + device_id=f"test-sensor-device-id-{i}", + manufacturer=f"test-sensor-manufacturer-{i}", + model=f"test-sensor-model-{i}", + name=f"test-sensor-name-{i}", + altitude=0.0, + atmospheric_pressure=0.0, + battery_voltage=0.0, + dewpoint=0.0, + humidity=0.0, + last_update=dt_util.utcnow(), + signal_strength=0.0, + temperature=0.0, + vapor_pressure=0.0, + ) + for i in range(NUM_MOCK_DEVICES) +} diff --git a/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr b/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a78b012ac02 --- /dev/null +++ b/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr @@ -0,0 +1,1267 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_sensor_name_0_altitude-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_altitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Altitude', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'altitude', + 'unique_id': 'test-sensor-device-id-0_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'test-sensor-name-0 Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_atmospheric_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'test-sensor-name-0 Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'test-sensor-device-id-0_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test-sensor-name-0 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': 'test-sensor-device-id-0_dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-0 Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'test-sensor-name-0 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'test-sensor-name-0 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_vapor_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_0_vapor_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vapor pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vapor_pressure', + 'unique_id': 'test-sensor-device-id-0_vapor_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_vapor_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'test-sensor-name-0 Vapor pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_vapor_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_altitude-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_altitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Altitude', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'altitude', + 'unique_id': 'test-sensor-device-id-1_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'test-sensor-name-1 Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_atmospheric_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'test-sensor-name-1 Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'test-sensor-device-id-1_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test-sensor-name-1 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': 'test-sensor-device-id-1_dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-1 Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'test-sensor-name-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'test-sensor-name-1 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_vapor_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_1_vapor_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vapor pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vapor_pressure', + 'unique_id': 'test-sensor-device-id-1_vapor_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_vapor_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'test-sensor-name-1 Vapor pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_vapor_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_altitude-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_altitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Altitude', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'altitude', + 'unique_id': 'test-sensor-device-id-2_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'test-sensor-name-2 Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_atmospheric_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'test-sensor-name-2 Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'test-sensor-device-id-2_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test-sensor-name-2 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': 'test-sensor-device-id-2_dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-2 Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'test-sensor-name-2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'test-sensor-name-2 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_vapor_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_sensor_name_2_vapor_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vapor pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vapor_pressure', + 'unique_id': 'test-sensor-device-id-2_vapor_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_vapor_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'test-sensor-name-2 Vapor pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_vapor_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/sensorpush_cloud/test_config_flow.py b/tests/components/sensorpush_cloud/test_config_flow.py new file mode 100644 index 00000000000..dc88c638b9b --- /dev/null +++ b/tests/components/sensorpush_cloud/test_config_flow.py @@ -0,0 +1,95 @@ +"""Test the SensorPush Cloud config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from sensorpush_ha import SensorPushCloudAuthError + +from homeassistant.components.sensorpush_cloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import CONF_DATA, CONF_EMAIL + +from tests.common import MockConfigEntry + + +async def test_user( + hass: HomeAssistant, + mock_api: AsyncMock, + mock_helper: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONF_DATA, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == CONF_DATA + assert result["result"].unique_id == CONF_DATA[CONF_EMAIL] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_already_configured( + hass: HomeAssistant, + mock_api: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we fail on a duplicate entry in the user flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error", "expected"), + [(SensorPushCloudAuthError, "invalid_auth"), (Exception, "unknown")], +) +async def test_user_error( + hass: HomeAssistant, + mock_api: AsyncMock, + mock_setup_entry: AsyncMock, + error: Exception, + expected: str, +) -> None: + """Test we display errors in the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_api.async_authorize.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONF_DATA + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected} + + # Show we can recover from errors: + mock_api.async_authorize.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONF_DATA + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == CONF_DATA + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sensorpush_cloud/test_sensor.py b/tests/components/sensorpush_cloud/test_sensor.py new file mode 100644 index 00000000000..c35d40f1bc2 --- /dev/null +++ b/tests/components/sensorpush_cloud/test_sensor.py @@ -0,0 +1,29 @@ +"""Test SensorPush Cloud sensors.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_helper: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test we can read sensors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/seventeentrack/test_repairs.py b/tests/components/seventeentrack/test_repairs.py deleted file mode 100644 index 44d1f078432..00000000000 --- a/tests/components/seventeentrack/test_repairs.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for the seventeentrack repair flow.""" - -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory - -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN -from homeassistant.components.seventeentrack import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from . import goto_future, init_integration -from .conftest import DEFAULT_SUMMARY_LENGTH, get_package - -from tests.common import MockConfigEntry -from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow -from tests.typing import ClientSessionGenerator - - -async def test_repair( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Ensure everything starts correctly.""" - await init_integration(hass, mock_config_entry) # 2 - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - assert len(issue_registry.issues) == 1 - - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - assert "deprecated" not in mock_config_entry.data - - repair_issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"deprecate_sensor_{mock_config_entry.entry_id}" - ) - - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - - client = await hass_client() - - data = await start_repair_fix_flow(client, DOMAIN, repair_issue.issue_id) - - flow_id = data["flow_id"] - assert data == { - "type": "form", - "flow_id": flow_id, - "handler": DOMAIN, - "step_id": "confirm", - "data_schema": [], - "errors": None, - "description_placeholders": None, - "last_step": None, - "preview": None, - } - - data = await process_repair_fix_flow(client, flow_id) - - flow_id = data["flow_id"] - assert data == { - "type": "create_entry", - "handler": DOMAIN, - "flow_id": flow_id, - "description": None, - "description_placeholders": None, - } - - assert mock_config_entry.data["deprecated"] - - repair_issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecate_sensor" - ) - - assert repair_issue is None - - await goto_future(hass, freezer) - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index a631996b4eb..5367fabba9e 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError @@ -63,87 +63,6 @@ async def test_login_exception( assert not hass.states.async_entity_ids("sensor") -async def test_add_package( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package is added correctly when user add a new package.""" - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - assert hass.states.get("sensor.17track_package_friendly_name_1") - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - package2 = get_package( - tracking_number="789", - friendly_name="friendly name 2", - info_text="info text 2", - location="location 2", - timestamp="2020-08-10 14:25", - ) - mock_seventeentrack.return_value.profile.packages.return_value = [package, package2] - - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 2 - - -async def test_add_package_default_friendly_name( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package is added correctly with default friendly name when user add a new package without his own friendly name.""" - package = get_package(friendly_name=None) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - state_456 = hass.states.get("sensor.17track_package_456") - assert state_456 is not None - assert state_456.attributes["friendly_name"] == "17Track Package 456" - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - -async def test_remove_package( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure entity is not there anymore if package is not there.""" - package1 = get_package() - package2 = get_package( - tracking_number="789", - friendly_name="friendly name 2", - info_text="info text 2", - location="location 2", - timestamp="2020-08-10 14:25", - ) - - mock_seventeentrack.return_value.profile.packages.return_value = [ - package1, - package2, - ] - - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert hass.states.get("sensor.17track_package_friendly_name_2") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 2 - - mock_seventeentrack.return_value.profile.packages.return_value = [package2] - - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is None - assert hass.states.get("sensor.17track_package_friendly_name_2") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - async def test_package_error( hass: HomeAssistant, mock_seventeentrack: AsyncMock, @@ -159,72 +78,6 @@ async def test_package_error( assert hass.states.get("sensor.17track_package_friendly_name_1") is None -async def test_delivered_not_shown( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry_with_default_options: MockConfigEntry, -) -> None: - """Ensure delivered packages are not shown.""" - package = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await init_integration(hass, mock_config_entry_with_default_options) - await goto_future(hass, freezer) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is None - persistent_notification_mock.create.assert_called() - - -async def test_delivered_shown( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure delivered packages are show when user choose to show them.""" - package = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - persistent_notification_mock.create.assert_not_called() - - -async def test_becomes_delivered_not_shown_notification( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry_with_default_options: MockConfigEntry, -) -> None: - """Ensure notification is triggered when package becomes delivered.""" - package = get_package() - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry_with_default_options) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - - package_delivered = get_package(status=40) - mock_seventeentrack.return_value.profile.packages.return_value = [package_delivered] - - with patch( - "homeassistant.components.seventeentrack.sensor.persistent_notification" - ) as persistent_notification_mock: - await goto_future(hass, freezer) - - persistent_notification_mock.create.assert_called() - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - - async def test_summary_correctly_updated( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -237,7 +90,7 @@ async def test_summary_correctly_updated( await init_integration(hass, mock_config_entry) - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 + assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") assert state_ready_picked is not None @@ -278,25 +131,6 @@ async def test_summary_error( ) -async def test_utc_timestamp( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure package timestamp is converted correctly from HA-defined time zone to UTC.""" - - package = get_package(tz="Asia/Jakarta") - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - - assert hass.states.get("sensor.17track_package_friendly_name_1") is not None - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH + 1 - state_456 = hass.states.get("sensor.17track_package_friendly_name_1") - assert state_456 is not None - assert str(state_456.attributes.get("timestamp")) == "2020-08-10 03:32:00+00:00" - - async def test_non_valid_platform_config( hass: HomeAssistant, mock_seventeentrack: AsyncMock ) -> None: diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 15308fad91f..4718abc02b5 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +74,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -132,6 +135,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -169,6 +173,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +205,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index 67b2198fd2b..68a1e7f7227 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 7645a4ad8bf..56745c8be8e 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -48,6 +49,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -79,6 +81,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -110,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -149,6 +153,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -180,6 +185,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -211,6 +217,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -242,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -275,6 +283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -308,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -341,6 +351,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -374,6 +385,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -407,6 +419,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,7 +486,7 @@ 'capabilities': dict({ 'options': list([ 'no_defect', - 'of_frame', + 'loss_of_frame', 'loss_of_signal', 'loss_of_power', 'loss_of_signal_quality', @@ -480,6 +494,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -524,6 +539,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -739,7 +755,7 @@ 'friendly_name': 'SFR Box DSL line status', 'options': list([ 'no_defect', - 'of_frame', + 'loss_of_frame', 'loss_of_signal', 'loss_of_power', 'loss_of_signal_quality', diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7de45eeee98..7a20560e25f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -148,6 +148,13 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: return entity.state +def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: + """Return entity attribute.""" + entity = hass.states.get(entity_id) + assert entity + return entity.attributes[attribute] + + def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index b2550c2b9d4..0063c5c2697 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,9 +1,20 @@ """Test configuration for Shelly.""" +from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from aioshelly.ble.const import ( + BLE_CODE, + BLE_SCAN_RESULT_EVENT, + BLE_SCAN_RESULT_VERSION, + BLE_SCRIPT_NAME, + VAR_ACTIVE, + VAR_EVENT_TYPE, + VAR_VERSION, +) from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_1, MODEL_25, MODEL_PLUS_2PM +from aioshelly.exceptions import NotInitialized from aioshelly.rpc_device import RpcDevice, RpcUpdateType import pytest @@ -90,6 +101,7 @@ MOCK_BLOCKS = [ "overpower": 0, "power": 53.4, "energy": 1234567.89, + "output": True, }, channel="0", type="relay", @@ -180,6 +192,7 @@ MOCK_CONFIG = { "xcounts": {"expr": None, "unit": None}, "xfreq": {"expr": None, "unit": None}, }, + "flood:0": {"id": 0, "name": "Test name"}, "light:0": {"name": "test light_0"}, "light:1": {"name": "test light_1"}, "light:2": {"name": "test light_2"}, @@ -195,13 +208,76 @@ MOCK_CONFIG = { }, "sys": { "ui_data": {}, - "device": {"name": "Test name"}, + "device": {"name": "Test name", "mac": MOCK_MAC}, }, "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, "voltmeter:100": {"xvoltage": {"unit": "ppm"}}, + "script:1": {"id": 1, "name": "test_script.js", "enable": True}, + "script:2": {"id": 2, "name": "test_script_2.js", "enable": False}, + "script:3": {"id": 3, "name": BLE_SCRIPT_NAME, "enable": False}, } + +MOCK_BLU_TRV_REMOTE_CONFIG = { + "components": [ + { + "key": "blutrv:200", + "status": { + "id": 200, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 200, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "key": None, + "trv": "bthomedevice:200", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, + ], + "blutrv:200": { + "id": 0, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:dd", + "name": "TRV-Name", + "local_name": "SBTR-001AEU", + }, +} + + +MOCK_BLU_TRV_REMOTE_STATUS = { + "blutrv:200": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "rssi": -60, + "battery": 100, + "errors": [], + }, +} + + MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, @@ -237,7 +313,11 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { - "switch:0": {"output": True}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + }, "input:0": {"id": 0, "state": None}, "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, "input:2": { @@ -266,6 +346,7 @@ MOCK_STATUS_RPC = { "em1:1": {"act_power": 123.3}, "em1data:0": {"total_act_energy": 123456.4}, "em1data:1": {"total_act_energy": 987654.3}, + "flood:0": {"id": 0, "alarm": False, "mute": False}, "thermostat:0": { "id": 0, "enable": True, @@ -273,6 +354,15 @@ MOCK_STATUS_RPC = { "current_C": 12.3, "output": True, }, + "script:1": { + "id": 1, + "running": True, + "mem_used": 826, + "mem_peak": 1666, + "mem_free": 24360, + }, + "script:2": {"id": 2, "running": False}, + "script:3": {"id": 3, "running": False}, "humidity:0": {"rh": 44.4}, "sys": { "available_updates": { @@ -285,6 +375,28 @@ MOCK_STATUS_RPC = { "wifi": {"rssi": -63}, } +MOCK_SCRIPTS = [ + """" +function eventHandler(event, userdata) { + if (typeof event.component !== "string") + return; + + let component = event.component.substring(0, 5); + if (component === "input") { + let id = Number(event.component.substring(6)); + Shelly.emitEvent("input_event", { id: id }); + } +} + +Shelly.addEventHandler(eventHandler); +Shelly.emitEvent("script_start"); +""", + 'console.log("Hello World!")', + BLE_CODE.replace(VAR_ACTIVE, "true") + .replace(VAR_EVENT_TYPE, BLE_SCAN_RESULT_EVENT) + .replace(VAR_VERSION, str(BLE_SCAN_RESULT_VERSION)), +] + @pytest.fixture(autouse=True) def mock_coap(): @@ -368,6 +480,28 @@ def _mock_rpc_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + script_getcode=AsyncMock( + side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + ), + xmod_info={}, + ) + type(device).name = PropertyMock(return_value="Test name") + return device + + +def _mock_blu_rtv_device(version: str | None = None): + """Mock rpc (Gen2, Websocket) device.""" + device = Mock( + spec=RpcDevice, + config=MOCK_CONFIG | MOCK_BLU_TRV_REMOTE_CONFIG, + event={}, + shelly=MOCK_SHELLY_RPC, + version=version or "1.0.0", + hostname="test-host", + status=MOCK_STATUS_RPC | MOCK_BLU_TRV_REMOTE_STATUS, + firmware_version="some fw string", + initialized=True, + connected=True, ) type(device).name = PropertyMock(return_value="Test name") return device @@ -420,3 +554,120 @@ async def mock_rpc_device(): @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=True) +async def mock_blu_trv(): + """Mock BLU TRV.""" + + with ( + patch("aioshelly.rpc_device.RpcDevice.create") as blu_trv_device_mock, + patch("homeassistant.components.shelly.bluetooth.async_start_scanner"), + ): + + def update(): + blu_trv_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + device = _mock_blu_rtv_device() + blu_trv_device_mock.return_value = device + blu_trv_device_mock.return_value.mock_update = Mock(side_effect=update) + + yield blu_trv_device_mock.return_value + + +def _mock_sleepy_not_initialized_rpc_device(): + """Mock sleepy NotInitialized rpc (Gen2+, Websocket) device.""" + device = Mock(spec=RpcDevice, initialized=False, connected=False) + type(device).requires_auth = PropertyMock(side_effect=NotInitialized) + type(device).status = PropertyMock(side_effect=NotInitialized) + type(device).event = PropertyMock(side_effect=NotInitialized) + type(device).config = PropertyMock(side_effect=NotInitialized) + type(device).shelly = PropertyMock(side_effect=NotInitialized) + type(device).gen = PropertyMock(side_effect=NotInitialized) + type(device).firmware_version = PropertyMock(side_effect=NotInitialized) + type(device).version = PropertyMock(side_effect=NotInitialized) + type(device).model = PropertyMock(side_effect=NotInitialized) + type(device).xmod_info = PropertyMock(side_effect=NotInitialized) + type(device).hostname = PropertyMock(side_effect=NotInitialized) + type(device).name = PropertyMock(side_effect=NotInitialized) + type(device).firmware_supported = PropertyMock(side_effect=NotInitialized) + return device + + +def initialize_sleepy_rpc_device(device): + """Initialize a sleepy RPC (Gen2+, Websocket) device.""" + status = deepcopy(MOCK_STATUS_RPC) + status["sys"]["wakeup_period"] = 1000 + + type(device).requires_auth = PropertyMock(return_value=False) + type(device).status = PropertyMock(return_value=status) + type(device).event = PropertyMock(return_value={}) + type(device).config = PropertyMock(return_value=MOCK_CONFIG) + type(device).shelly = PropertyMock(return_value=MOCK_SHELLY_RPC) + type(device).gen = PropertyMock(return_value=2) + type(device).firmware_version = PropertyMock( + return_value="20240425-141520/1.3.0-ga3fdd3d" + ) + type(device).version = PropertyMock(return_value="1.3.0") + type(device).model = PropertyMock(return_value="SPSW-201PE16EU") + type(device).xmod_info = PropertyMock(return_value={}) + type(device).hostname = PropertyMock(return_value="hostname") + type(device).name = PropertyMock(return_value="Test Name") + type(device).firmware_supported = PropertyMock(return_value=True) + + device.connected = True + device.initialized = True + + +@pytest.fixture +async def mock_sleepy_rpc_device(): + """Mock sleepy RPC (Gen2+, Websocket) device. + + Mock a RPC device that is not initialized and raises NotInitialized + when aioshelly properties are accessed. + + Initialize the device when initialize() method is called. + """ + with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: + + def update(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + def event(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.EVENT + ) + + def online(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.ONLINE + ) + + def disconnected(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.DISCONNECTED + ) + + def initialized(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.INITIALIZED + ) + + def _initialize(): + initialize_sleepy_rpc_device(device) + + device = _mock_sleepy_not_initialized_rpc_device() + device.initialize = AsyncMock(side_effect=_initialize) + rpc_device_mock.return_value = device + + rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) + rpc_device_mock.return_value.mock_update = Mock(side_effect=update) + rpc_device_mock.return_value.mock_event = Mock(side_effect=event) + rpc_device_mock.return_value.mock_online = Mock(side_effect=online) + rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) + + yield rpc_device_mock.return_value diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..fcc6377837e --- /dev/null +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -0,0 +1,144 @@ +# serializer version: 1 +# name: test_blu_trv_binary_sensor_entity[binary_sensor.trv_name_calibration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.trv_name_calibration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name calibration', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-calibration', + 'unit_of_measurement': None, + }) +# --- +# name: test_blu_trv_binary_sensor_entity[binary_sensor.trv_name_calibration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'TRV-Name calibration', + }), + 'context': , + 'entity_id': 'binary_sensor.trv_name_calibration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_flood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_name_flood', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name flood', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-flood', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test name flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_name_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test name mute', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-mute', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name mute', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_event.ambr b/tests/components/shelly/snapshots/test_event.ambr new file mode 100644 index 00000000000..ae719774aee --- /dev/null +++ b/tests/components/shelly/snapshots/test_event.ambr @@ -0,0 +1,70 @@ +# serializer version: 1 +# name: test_rpc_script_1_event[event.test_name_test_script_js-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'input_event', + 'script_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_name_test_script_js', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'test_script.js', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'script', + 'unique_id': '123456789ABC-script:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_script_1_event[event.test_name_test_script_js-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'input_event', + 'script_start', + ]), + 'friendly_name': 'Test name test_script.js', + }), + 'context': , + 'entity_id': 'event.test_name_test_script_js', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_script_2_event[event.test_name_test_script_2_js-entry] + None +# --- +# name: test_rpc_script_2_event[event.test_name_test_script_2_js-state] + None +# --- +# name: test_rpc_script_ble_event[event.test_name_aioshelly_ble_integration-entry] + None +# --- +# name: test_rpc_script_ble_event[event.test_name_aioshelly_ble_integration-state] + None +# --- diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr new file mode 100644 index 00000000000..07fda999556 --- /dev/null +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_blu_trv_number_entity[number.trv_name_external_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 50, + 'min': -50, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.trv_name_external_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name external temperature', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': '123456789ABC-blutrv:200-external_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name external temperature', + 'max': 50, + 'min': -50, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.trv_name_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.trv_name_valve_position', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name valve position', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '123456789ABC-blutrv:200-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_number_entity[number.trv_name_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name valve position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.trv_name_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..cb39b148c8a --- /dev/null +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -0,0 +1,156 @@ +# serializer version: 1 +# name: test_blu_trv_sensor_entity[sensor.trv_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name battery', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-blutrv_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'TRV-Name battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.trv_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_signal_strength', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TRV-Name signal strength', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-blutrv:200-blutrv_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TRV-Name signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.trv_name_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.trv_name_valve_position', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name valve position', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '123456789ABC-blutrv:200-valve_position', + 'unit_of_measurement': '%', + }) +# --- +# name: test_blu_trv_sensor_entity[sensor.trv_name_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.trv_name_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index fadfe28db3e..1e7c54320e8 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -3,15 +3,15 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_MOTION +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -387,7 +387,7 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( ) async def test_rpc_device_virtual_binary_sensor( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, name: str | None, @@ -422,7 +422,7 @@ async def test_rpc_device_virtual_binary_sensor( async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -456,7 +456,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( async def test_rpc_remove_virtual_binary_sensor_when_orphaned( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: @@ -477,3 +477,41 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +async def test_blu_trv_binary_sensor_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV binary sensor entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + for entity in ("calibration",): + entity_id = f"{BINARY_SENSOR_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_flood_entities( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC flood sensor entities.""" + await init_integration(hass, 4) + + for entity in ("flood", "mute"): + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index aeeeca30edd..c78e87ebfce 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -3,7 +3,12 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock -from aioshelly.const import MODEL_VALVE, MODEL_WALL_DISPLAY +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + MODEL_BLU_GATEWAY_G3, + MODEL_VALVE, + MODEL_WALL_DISPLAY, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest @@ -21,7 +26,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -37,7 +42,13 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import MOCK_MAC, init_integration, register_device, register_entity +from . import ( + MOCK_MAC, + get_entity_attribute, + init_integration, + register_device, + register_entity, +) from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -294,13 +305,13 @@ async def test_block_restored_climate( assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 -async def test_block_restored_climate_us_customery( +async def test_block_restored_climate_us_customary( hass: HomeAssistant, mock_block_device: Mock, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test block restored climate with US CUSTOMATY unit system.""" + """Test block restored climate with US CUSTOMARY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") @@ -740,6 +751,7 @@ async def test_wall_display_thermostat_mode_external_actuator( new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -759,3 +771,83 @@ async def test_wall_display_thermostat_mode_external_actuator( entry = entity_registry.async_get(climate_entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" + + +async def test_blu_trv_climate_set_temperature( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV set target temperature.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 28}, + blocking=True, + ) + mock_blu_trv.mock_update() + + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetTarget", + "params": {"id": 0, "target_C": 28.0}, + }, + BLU_TRV_TIMEOUT, + ) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + + +async def test_blu_trv_climate_disabled( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV disabled.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + + monkeypatch.setitem( + mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False + ) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + + +async def test_blu_trv_climate_hvac_action( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV is heating.""" + + entity_id = "climate.trv_name" + monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + + monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) + mock_blu_trv.mock_update() + + assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index d9945706182..50b8b552268 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -15,7 +15,6 @@ from aioshelly.exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, @@ -25,6 +24,10 @@ from homeassistant.components.shelly.const import ( from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -33,22 +36,22 @@ from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ) -DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( +DISCOVERY_INFO_WITH_MAC = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-AABBCCDDEEFF"}, type="mock_type", ) @@ -114,6 +117,73 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_flow_overrides_existing_discovery( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test setting up from the user flow when the devices is already discovered.""" + with ( + patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "AABBCCDDEEFF", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "port": 80, + }, + ), + patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.shelly.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZeroconfServiceInfo( + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], + hostname="mock_hostname", + name="shelly2pm-aabbccddeeff", + port=None, + properties={ATTR_PROPERTIES_ID: "shelly2pm-aabbccddeeff"}, + type="mock_type", + ), + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert discovery_result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1", "port": 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test name" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 80, + "model": MODEL_PLUS_2PM, + "sleep_period": 0, + "gen": 2, + } + assert result2["context"]["unique_id"] == "AABBCCDDEEFF" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + # discovery flow should have been aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) + + async def test_form_gen1_custom_port( hass: HomeAssistant, mock_block_device: Mock, @@ -1459,13 +1529,13 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="shelly1pm-12345", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"}, + properties={ATTR_PROPERTIES_ID: "shelly1pm-12345"}, type="mock_type", ), ) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 090c5e7207f..8de434d19d0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -386,6 +386,8 @@ async def test_rpc_reload_on_cfg_change( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC reload on config change.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) # Generate config change from switch to light @@ -710,6 +712,8 @@ async def test_rpc_reconnect_error( exc: Exception, ) -> None: """Test RPC reconnect error.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON @@ -729,9 +733,12 @@ async def test_rpc_error_running_connected_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC error while running connected events.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) with patch( "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", side_effect=DeviceConnectionError, @@ -1011,3 +1018,22 @@ async def test_rpc_already_connected( assert "already connected" in caplog.text mock_rpc_device.initialize.assert_called_once() + + +async def test_xmod_model_lookup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test XMOD model look-up.""" + xmod_model = "Test XMOD model name" + monkeypatch.setattr(mock_rpc_device, "xmod_info", {"n": xmod_model}) + entry = await init_integration(hass, 2) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, + ) + assert device + assert device.model == xmod_model diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index f576524ba60..c0f78d48d9b 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -109,6 +109,8 @@ async def test_rpc_config_entry_diagnostics( "bluetooth": { "scanner": { "connectable": False, + "current_mode": None, + "requested_mode": None, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "discovered_devices_and_advertisement_data": [ { diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index 2465b016808..e184c154697 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -2,9 +2,11 @@ from unittest.mock import Mock +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.const import MODEL_I3 import pytest from pytest_unordered import unordered +from syrupy import SnapshotAssertion from homeassistant.components.event import ( ATTR_EVENT_TYPE, @@ -64,6 +66,99 @@ async def test_rpc_button( assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_script_1_event( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test script event.""" + await init_integration(hass, 2) + entity_id = "event.test_name_test_script_js" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "id": 1, + "event": "script_start", + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "script_start" + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "id": 1, + "event": "unknown_event", + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) != "unknown_event" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_script_2_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that scripts without any emitEvent will not get an event entity.""" + await init_integration(hass, 2) + entity_id = "event.test_name_test_script_2_js" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_rpc_script_ble_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the ble script will not get an event entity.""" + await init_integration(hass, 2) + entity_id = f"event.test_name_{BLE_SCRIPT_NAME}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + async def test_rpc_event_removal( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b5516485501..f3ce807b655 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -312,13 +312,10 @@ async def test_sleeping_rpc_device_online_new_firmware( async def test_sleeping_rpc_device_online_during_setup( hass: HomeAssistant, - mock_rpc_device: Mock, - monkeypatch: pytest.MonkeyPatch, + mock_sleepy_rpc_device: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test sleeping device Gen2 woke up by user during setup.""" - monkeypatch.setattr(mock_rpc_device, "connected", False) - monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) await init_integration(hass, 2, sleep_period=1000) await hass.async_block_till_done(wait_background_tasks=True) @@ -369,8 +366,11 @@ async def test_entry_unload( entity_id: str, mock_block_device: Mock, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test entry unload.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED @@ -413,6 +413,9 @@ async def test_entry_unload_not_connected( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: @@ -438,6 +441,9 @@ async def test_entry_unload_not_connected_but_we_think_we_are( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected but we think we are still connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, @@ -545,3 +551,22 @@ async def test_sleeping_block_device_wrong_sleep_period( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_EXPECTED_SLEEP_PERIOD + + +async def test_bluetooth_cleanup_on_remove_entry( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test bluetooth is cleaned up on entry removal.""" + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + with patch("homeassistant.components.shelly.async_remove_scanner") as remove_mock: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + remove_mock.assert_called_once_with(hass, entry.unique_id.upper()) diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6c1cc394b64..6bddd1eeb23 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,8 +3,10 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.number import ( ATTR_MAX, @@ -16,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -390,3 +392,102 @@ async def test_rpc_remove_virtual_number_when_orphaned( entry = entity_registry.async_get(entity_id) assert not entry + + +async def test_blu_trv_number_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV number entity.""" + # disable automatic temperature control in the device + monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + for entity in ("external_temperature", "valve_position"): + entity_id = f"{NUMBER_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_blu_trv_ext_temp_set_value( + hass: HomeAssistant, mock_blu_trv: Mock +) -> None: + """Test the set value action for BLU TRV External Temperature number entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" + + # After HA start the state should be unknown because there was no previous external + # temperature report + assert hass.states.get(entity_id).state is STATE_UNKNOWN + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 22.2, + }, + blocking=True, + ) + mock_blu_trv.mock_update() + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetExternalTemperature", + "params": {"id": 0, "t_C": 22.2}, + }, + BLU_TRV_TIMEOUT, + ) + + assert hass.states.get(entity_id).state == "22.2" + + +async def test_blu_trv_valve_pos_set_value( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the set value action for BLU TRV Valve Position number entity.""" + # disable automatic temperature control to enable valve position entity + monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" + + assert hass.states.get(entity_id).state == "0" + + monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "pos", 20) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + mock_blu_trv.mock_update() + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetPosition", + "params": {"id": 0, "pos": 20}, + }, + BLU_TRV_TIMEOUT, + ) + # device only accepts int for 'pos' value + assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) + + assert hass.states.get(entity_id).state == "20" diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index c62f21d9c8f..d0fec65c7de 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -3,8 +3,10 @@ from copy import deepcopy from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, @@ -1405,3 +1407,51 @@ async def test_rpc_voltmeter_value( entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter_value" + + +async def test_blu_trv_sensor_entity( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test BLU TRV sensor entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + for entity in ("battery", "signal_strength", "valve_position"): + entity_id = f"{SENSOR_DOMAIN}.trv_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_device_virtual_number_sensor_with_device_class( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test a virtual number sensor with device class for RPC device.""" + config = deepcopy(mock_rpc_device.config) + config["number:203"] = { + "name": "Current humidity", + "min": 0, + "max": 100, + "meta": {"ui": {"step": 1, "unit": "%", "view": "label"}}, + "role": "current_humidity", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:203"] = {"value": 34} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + state = hass.states.get("sensor.test_name_current_humidity") + assert state + assert state.state == "34" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5c7933afd7e..1e5ae9dd88c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -25,7 +25,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -289,6 +288,8 @@ async def test_rpc_device_services( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device turn on/off services.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) await hass.services.async_call( @@ -311,9 +312,14 @@ async def test_rpc_device_services( async def test_rpc_device_unique_ids( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test RPC device unique_ids.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) entry = entity_registry.async_get("switch.test_switch_0") @@ -341,6 +347,8 @@ async def test_rpc_set_state_errors( ) -> None: """Test RPC device set state connection/call errors.""" monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc)) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) with pytest.raises(HomeAssistantError): @@ -361,6 +369,8 @@ async def test_rpc_auth_error( "call_rpc", AsyncMock(side_effect=InvalidAuthError), ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.LOADED @@ -410,15 +420,22 @@ async def test_wall_display_relay_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in relay mode.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" + config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert hass.states.get(climate_entity_id) is not None + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False new_status.pop("thermostat:0") + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) - await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() # the climate entity should be removed assert hass.states.get(climate_entity_id) is None @@ -444,7 +461,7 @@ async def test_wall_display_relay_mode( ) async def test_rpc_device_virtual_switch( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, name: str | None, @@ -517,7 +534,7 @@ async def test_rpc_device_virtual_binary_sensor( async def test_rpc_remove_virtual_switch_when_mode_label( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -551,7 +568,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( async def test_rpc_remove_virtual_switch_when_orphaned( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, device_registry: DeviceRegistry, mock_rpc_device: Mock, ) -> None: @@ -577,7 +594,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_device_script_switch( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index cd4cdf877a5..9ea66c1acb7 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.shelly.const import ( DOMAIN, GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, ) from homeassistant.components.update import ( @@ -572,7 +573,6 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_LATEST_VERSION] == "1" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - assert state.attributes[ATTR_RELEASE_URL] is None monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -589,7 +589,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False - assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None + assert state.attributes[ATTR_RELEASE_URL] == GEN2_BETA_RELEASE_URL await hass.services.async_call( UPDATE_DOMAIN, diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 17bcd6e3d40..b7c3dff10f6 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -17,7 +17,11 @@ from aioshelly.const import ( ) import pytest -from homeassistant.components.shelly.const import GEN1_RELEASE_URL, GEN2_RELEASE_URL +from homeassistant.components.shelly.const import ( + GEN1_RELEASE_URL, + GEN2_BETA_RELEASE_URL, + GEN2_RELEASE_URL, +) from homeassistant.components.shelly.utils import ( get_block_channel_name, get_block_device_sleep_period, @@ -300,7 +304,7 @@ async def test_get_rpc_input_triggers( (1, MODEL_1, True, None), (2, MODEL_WALL_DISPLAY, False, None), (2, MODEL_PLUS_2PM_V2, False, GEN2_RELEASE_URL), - (2, MODEL_PLUS_2PM_V2, True, None), + (2, MODEL_PLUS_2PM_V2, True, GEN2_BETA_RELEASE_URL), ], ) def test_get_release_url( diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index b35ce98b664..9dc8597120a 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -17,7 +17,7 @@ GAS_VALVE_BLOCK_ID = 6 async def test_block_device_gas_valve( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + entity_registry: EntityRegistry, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 5db6347a832..ba03f6fc804 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -11,7 +11,7 @@ import pytest import simplehound.core as hound from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN, SERVICE_SCAN -import homeassistant.components.sighthound.image_processing as sh +from homeassistant.components.sighthound import image_processing as sh from homeassistant.const import ( ATTR_ENTITY_ID, CONF_API_KEY, diff --git a/tests/components/simplefin/snapshots/test_binary_sensor.ambr b/tests/components/simplefin/snapshots/test_binary_sensor.ambr index 44fe2a10b78..3123100205e 100644 --- a/tests/components/simplefin/snapshots/test_binary_sensor.ambr +++ b/tests/components/simplefin/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/simplefin/snapshots/test_sensor.ambr b/tests/components/simplefin/snapshots/test_sensor.ambr index c7dced9300e..dd305f7528f 100644 --- a/tests/components/simplefin/snapshots/test_sensor.ambr +++ b/tests/components/simplefin/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +214,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -261,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,6 +420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -513,6 +523,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -564,6 +575,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +678,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -715,6 +729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +781,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index d5479f00b06..13c1e28aa36 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -32,6 +32,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "subscription_data": { "12345": { diff --git a/tests/components/slack/__init__.py b/tests/components/slack/__init__.py index acb52a11a6c..507e96294ff 100644 --- a/tests/components/slack/__init__.py +++ b/tests/components/slack/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -AUTH_URL = "https://www.slack.com/api/auth.test" +AUTH_URL = "https://slack.com/api/auth.test" TOKEN = "abc123" TEAM_NAME = "Test Team" diff --git a/tests/components/slack/test_config_flow.py b/tests/components/slack/test_config_flow.py index 565b5ec2149..6d0953da5e9 100644 --- a/tests/components/slack/test_config_flow.py +++ b/tests/components/slack/test_config_flow.py @@ -81,7 +81,7 @@ async def test_flow_user_cannot_connect( async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: """Test user initialized flow with unreachable server.""" with patch( - "homeassistant.components.slack.config_flow.WebClient.auth_test" + "homeassistant.components.slack.config_flow.AsyncWebClient.auth_test" ) as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( diff --git a/tests/components/slide_local/snapshots/test_button.ambr b/tests/components/slide_local/snapshots/test_button.ambr index 549538f1361..7b363f4d9ba 100644 --- a/tests/components/slide_local/snapshots/test_button.ambr +++ b/tests/components/slide_local/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/slide_local/snapshots/test_cover.ambr b/tests/components/slide_local/snapshots/test_cover.ambr index d9283618a47..172f5411a94 100644 --- a/tests/components/slide_local/snapshots/test_cover.ambr +++ b/tests/components/slide_local/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/slide_local/snapshots/test_diagnostics.ambr b/tests/components/slide_local/snapshots/test_diagnostics.ambr index 63dab3f5a66..7606c2a399b 100644 --- a/tests/components/slide_local/snapshots/test_diagnostics.ambr +++ b/tests/components/slide_local/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'slide', 'unique_id': '12:34:56:78:90:ab', 'version': 1, diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index d90f72e4b05..5b1a9f5ce2f 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.2', 'connections': set({ tuple( diff --git a/tests/components/slide_local/snapshots/test_switch.ambr b/tests/components/slide_local/snapshots/test_switch.ambr index e19467c283e..9b1a7969539 100644 --- a/tests/components/slide_local/snapshots/test_switch.ambr +++ b/tests/components/slide_local/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/slide_local/test_config_flow.py b/tests/components/slide_local/test_config_flow.py index 9f2923988ca..b8b69d99fd8 100644 --- a/tests/components/slide_local/test_config_flow.py +++ b/tests/components/slide_local/test_config_flow.py @@ -12,11 +12,11 @@ from goslideapi.goslideapi import ( import pytest from homeassistant.components.slide_local.const import CONF_INVERT_POSITION, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import setup_platform from .const import HOST, SLIDE_INFO_DATA diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..14b0d120190 --- /dev/null +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + 'group': 'user', + 'host': '1.1.1.1', + 'password': '**REDACTED**', + 'ssl': True, + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'sma', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'import', + 'subentries': list([ + ]), + 'title': 'SMA Device Name', + 'unique_id': '123456789', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/sma/test_diagnostics.py b/tests/components/sma/test_diagnostics.py new file mode 100644 index 00000000000..6c1fe0dc5cb --- /dev/null +++ b/tests/components/sma/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Test the SMA diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index c06ab551ef6..205c700a402 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components import zeroconf from homeassistant.components.smappee.const import ( CONF_SERIALNUMBER, DOMAIN, @@ -20,6 +19,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,7 +63,7 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -95,7 +95,7 @@ async def test_show_zeroconf_connection_error_form_next_generation( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -179,7 +179,7 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -305,7 +305,7 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -355,7 +355,7 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -377,7 +377,7 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -504,7 +504,7 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, @@ -589,7 +589,7 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], port=22, diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 5a3e9135963..6939d3c5dcc 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -1 +1,77 @@ -"""Tests for the SmartThings component.""" +"""Tests for the SmartThings integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from pysmartthings.models import Attribute, Capability, DeviceEvent +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def snapshot_smartthings_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot SmartThings entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +def set_attribute_value( + mock: AsyncMock, + capability: Capability, + attribute: Attribute, + value: Any, + component: str = MAIN, +) -> None: + """Set the value of an attribute.""" + mock.get_device_status.return_value[component][capability][attribute].value = value + + +async def trigger_update( + hass: HomeAssistant, + mock: AsyncMock, + device_id: str, + capability: Capability, + attribute: Attribute, + value: str | float | dict[str, Any] | list[Any] | None, + data: dict[str, Any] | None = None, +) -> None: + """Trigger an update.""" + event = DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][3](event) + for call in mock.add_device_capability_event_listener.call_args_list: + if call[0][0] == device_id and call[0][2] == capability: + call[0][3](event) + await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 71a36c7885a..a47f32d3a8b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -1,358 +1,182 @@ """Test configuration and mocks for the SmartThings component.""" -import secrets -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch -from pysmartthings import ( - CLASSIFICATION_AUTOMATION, - AppEntity, - AppOAuthClient, - AppSettings, - DeviceEntity, +from pysmartthings.models import ( + DeviceResponse, DeviceStatus, - InstalledApp, - InstalledAppStatus, - InstalledAppType, - Location, - SceneEntity, - SmartThings, - Subscription, + LocationResponse, + RoomResponse, + SceneResponse, ) -from pysmartthings.api import Api import pytest -from homeassistant.components import webhook -from homeassistant.components.smartthings import DeviceBroker +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.smartthings import CONF_INSTALLED_APP_ID from homeassistant.components.smartthings.const import ( - APP_NAME_PREFIX, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, - DATA_BROKERS, DOMAIN, - SETTINGS_INSTANCE_ID, - STORAGE_KEY, - STORAGE_VERSION, -) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, + SCOPES, ) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.light.conftest import mock_light_profiles # noqa: F401 - -COMPONENT_PREFIX = "homeassistant.components.smartthings." +from tests.common import MockConfigEntry, load_fixture -async def setup_platform( - hass: HomeAssistant, platform: str, *, devices=None, scenes=None -): - """Set up the SmartThings platform and prerequisites.""" - hass.config.components.add(DOMAIN) - config_entry = MockConfigEntry( - version=2, - domain=DOMAIN, - title="Test", - data={CONF_INSTALLED_APP_ID: str(uuid4())}, - ) - config_entry.add_to_hass(hass) - broker = DeviceBroker( - hass, config_entry, Mock(), Mock(), devices or [], scenes or [] - ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.smartthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry - hass.data[DOMAIN] = {DATA_BROKERS: {config_entry.entry_id: broker}} - config_entry.mock_state(hass, ConfigEntryState.LOADED) - await hass.config_entries.async_forward_entry_setups(config_entry, [platform]) - await hass.async_block_till_done() - return config_entry + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 @pytest.fixture(autouse=True) -async def setup_component( - hass: HomeAssistant, config_file: dict[str, str], hass_storage: dict[str, Any] -) -> None: - """Load the SmartThing component.""" - hass_storage[STORAGE_KEY] = {"data": config_file, "version": STORAGE_VERSION} - await async_process_ha_core_config( +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( hass, - {"external_url": "https://test.local"}, - ) - await async_setup_component(hass, "smartthings", {}) - - -def _create_location() -> Mock: - loc = Mock(Location) - loc.name = "Test Location" - loc.location_id = str(uuid4()) - return loc - - -@pytest.fixture(name="location") -def location_fixture() -> Mock: - """Fixture for a single location.""" - return _create_location() - - -@pytest.fixture(name="locations") -def locations_fixture(location: Mock) -> list[Mock]: - """Fixture for 2 locations.""" - return [location, _create_location()] - - -@pytest.fixture(name="app") -async def app_fixture(hass: HomeAssistant, config_file: dict[str, str]) -> Mock: - """Fixture for a single app.""" - app = Mock(AppEntity) - app.app_name = APP_NAME_PREFIX + str(uuid4()) - app.app_id = str(uuid4()) - app.app_type = "WEBHOOK_SMART_APP" - app.classifications = [CLASSIFICATION_AUTOMATION] - app.display_name = "Home Assistant" - app.description = f"{hass.config.location_name} at https://test.local" - app.single_instance = True - app.webhook_target_url = webhook.async_generate_url( - hass, hass.data[DOMAIN][CONF_WEBHOOK_ID] + DOMAIN, + ClientCredential("CLIENT_ID", "CLIENT_SECRET"), + DOMAIN, ) - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - app.settings.return_value = settings - return app - -@pytest.fixture(name="app_oauth_client") -def app_oauth_client_fixture() -> Mock: - """Fixture for a single app's oauth.""" - client = Mock(AppOAuthClient) - client.client_id = str(uuid4()) - client.client_secret = str(uuid4()) - return client - - -@pytest.fixture(name="app_settings") -def app_settings_fixture(app, config_file): - """Fixture for an app settings.""" - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - return settings - - -def _create_installed_app(location_id: str, app_id: str) -> Mock: - item = Mock(InstalledApp) - item.installed_app_id = str(uuid4()) - item.installed_app_status = InstalledAppStatus.AUTHORIZED - item.installed_app_type = InstalledAppType.WEBHOOK_SMART_APP - item.app_id = app_id - item.location_id = location_id - return item - - -@pytest.fixture(name="installed_app") -def installed_app_fixture(location: Mock, app: Mock) -> Mock: - """Fixture for a single installed app.""" - return _create_installed_app(location.location_id, app.app_id) - - -@pytest.fixture(name="installed_apps") -def installed_apps_fixture(installed_app, locations, app): - """Fixture for 2 installed apps.""" - return [installed_app, _create_installed_app(locations[1].location_id, app.app_id)] - - -@pytest.fixture(name="config_file") -def config_file_fixture() -> dict[str, str]: - """Fixture representing the local config file contents.""" - return {CONF_INSTANCE_ID: str(uuid4()), CONF_WEBHOOK_ID: secrets.token_hex()} - - -@pytest.fixture(name="smartthings_mock") -def smartthings_mock_fixture(locations): - """Fixture to mock smartthings API calls.""" - - async def _location(location_id): - return next( - location for location in locations if location.location_id == location_id - ) - - smartthings_mock = Mock(SmartThings) - smartthings_mock.location.side_effect = _location - mock = Mock(return_value=smartthings_mock) +@pytest.fixture +def mock_smartthings() -> Generator[AsyncMock]: + """Mock a SmartThings client.""" with ( - patch(COMPONENT_PREFIX + "SmartThings", new=mock), - patch(COMPONENT_PREFIX + "config_flow.SmartThings", new=mock), - patch(COMPONENT_PREFIX + "smartapp.SmartThings", new=mock), + patch( + "homeassistant.components.smartthings.SmartThings", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.smartthings.config_flow.SmartThings", + new=mock_client, + ), ): - yield smartthings_mock + client = mock_client.return_value + client.get_scenes.return_value = SceneResponse.from_json( + load_fixture("scenes.json", DOMAIN) + ).items + client.get_locations.return_value = LocationResponse.from_json( + load_fixture("locations.json", DOMAIN) + ).items + client.get_rooms.return_value = RoomResponse.from_json( + load_fixture("rooms.json", DOMAIN) + ).items + yield client -@pytest.fixture(name="device") -def device_fixture(location): - """Fixture representing devices loaded.""" - item = Mock(DeviceEntity) - item.device_id = "743de49f-036f-4e9c-839a-2f89d57607db" - item.name = "GE In-Wall Smart Dimmer" - item.label = "Front Porch Lights" - item.location_id = location.location_id - item.capabilities = [ - "switch", - "switchLevel", - "refresh", - "indicator", - "sensor", - "actuator", - "healthCheck", - "light", +@pytest.fixture( + params=[ + "da_ac_rac_000001", + "da_ac_rac_01001", + "multipurpose_sensor", + "contact_sensor", + "base_electric_meter", + "smart_plug", + "vd_stv_2017_k", + "c2c_arlo_pro_3_switch", + "yale_push_button_deadbolt_lock", + "ge_in_wall_smart_dimmer", + "centralite", + "da_ref_normal_000001", + "vd_network_audio_002s", + "iphone", + "da_wm_dw_000001", + "da_wm_wd_000001", + "da_wm_wm_000001", + "da_rvc_normal_000001", + "da_ks_microwave_0101x", + "hue_color_temperature_bulb", + "hue_rgbw_color_bulb", + "c2c_shade", + "sonos_player", + "aeotec_home_energy_meter_gen5", + "virtual_water_sensor", + "virtual_thermostat", + "virtual_valve", + "sensibo_airconditioner_1", + "ecobee_sensor", + "ecobee_thermostat", + "fake_fan", ] - item.components = {"main": item.capabilities} - item.status = Mock(DeviceStatus) - return item +) +def device_fixture( + mock_smartthings: AsyncMock, request: pytest.FixtureRequest +) -> Generator[str]: + """Return every device.""" + return request.param -@pytest.fixture(name="config_entry") -def config_entry_fixture(installed_app: Mock, location: Mock) -> MockConfigEntry: - """Fixture representing a config entry.""" - data = { - CONF_ACCESS_TOKEN: str(uuid4()), - CONF_INSTALLED_APP_ID: installed_app.installed_app_id, - CONF_APP_ID: installed_app.app_id, - CONF_LOCATION_ID: location.location_id, - CONF_REFRESH_TOKEN: str(uuid4()), - CONF_CLIENT_ID: str(uuid4()), - CONF_CLIENT_SECRET: str(uuid4()), - } +@pytest.fixture +def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: + """Return a specific device.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture(f"devices/{device_fixture}.json", DOMAIN) + ).items + mock_smartthings.get_device_status.return_value = DeviceStatus.from_json( + load_fixture(f"device_status/{device_fixture}.json", DOMAIN) + ).components + return mock_smartthings + + +@pytest.fixture +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - data=data, - title=location.name, - version=2, - source=SOURCE_USER, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, ) -@pytest.fixture(name="subscription_factory") -def subscription_factory_fixture(): - """Fixture for creating mock subscriptions.""" - - def _factory(capability): - sub = Subscription() - sub.capability = capability - return sub - - return _factory - - -@pytest.fixture(name="device_factory") -def device_factory_fixture(): - """Fixture for creating mock devices.""" - api = Mock(Api) - api.post_device_command.return_value = {"results": [{"status": "ACCEPTED"}]} - - def _factory(label, capabilities, status: dict | None = None): - device_data = { - "deviceId": str(uuid4()), - "name": "Device Type Handler Name", - "label": label, - "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db", - "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80", - "components": [ - { - "id": "main", - "capabilities": [ - {"id": capability, "version": 1} for capability in capabilities - ], - } - ], - "dth": { - "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964", - "deviceTypeName": "Switch", - "deviceNetworkType": "ZWAVE", - }, - "type": "DTH", - } - device = DeviceEntity(api, data=device_data) - if status: - for attribute, value in status.items(): - device.status.apply_attribute_update("main", "", attribute, value) - return device - - return _factory - - -@pytest.fixture(name="scene_factory") -def scene_factory_fixture(location): - """Fixture for creating mock devices.""" - - def _factory(name): - scene = Mock(SceneEntity) - scene.scene_id = str(uuid4()) - scene.name = name - scene.icon = None - scene.color = None - scene.location_id = location.location_id - return scene - - return _factory - - -@pytest.fixture(name="scene") -def scene_fixture(scene_factory): - """Fixture for an individual scene.""" - return scene_factory("Test Scene") - - -@pytest.fixture(name="event_factory") -def event_factory_fixture(): - """Fixture for creating mock devices.""" - - def _factory( - device_id, - event_type="DEVICE_EVENT", - capability="", - attribute="Updated", - value="Value", - data=None, - ): - event = Mock() - event.event_type = event_type - event.device_id = device_id - event.component_id = "main" - event.capability = capability - event.attribute = attribute - event.value = value - event.data = data - event.location_id = str(uuid4()) - return event - - return _factory - - -@pytest.fixture(name="event_request_factory") -def event_request_factory_fixture(event_factory): - """Fixture for creating mock smartapp event requests.""" - - def _factory(device_ids=None, events=None): - request = Mock() - request.installed_app_id = uuid4() - if events is None: - events = [] - if device_ids: - events.extend([event_factory(device_id) for device_id in device_ids]) - events.append(event_factory(uuid4())) - events.append(event_factory(device_ids[0], event_type="OTHER")) - request.events = events - return request - - return _factory +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock the old config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + version=2, + ) diff --git a/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..95ae6310be8 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 2859.743, + "unit": "W", + "timestamp": "2025-02-10T21:09:08.228Z" + } + }, + "voltageMeasurement": { + "voltage": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null + } + }, + "energyMeter": { + "energy": { + "value": 19978.536, + "unit": "kWh", + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/base_electric_meter.json b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json new file mode 100644 index 00000000000..b4fa67b6f7e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 938.3, + "unit": "W", + "timestamp": "2025-02-09T17:56:21.748Z" + } + }, + "energyMeter": { + "energy": { + "value": 1930.362, + "unit": "kWh", + "timestamp": "2025-02-09T17:56:21.918Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..371a779f83c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -0,0 +1,82 @@ +{ + "components": { + "main": { + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "videoStream": { + "supportedFeatures": { + "value": null + }, + "stream": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-03T21:55:57.991Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "alarm": { + "alarm": { + "value": "off", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "refresh": {}, + "soundSensor": { + "sound": { + "value": "not detected", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T21:56:10.041Z" + }, + "type": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-08T21:56:10.041Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_shade.json b/tests/components/smartthings/fixtures/device_status/c2c_shade.json new file mode 100644 index 00000000000..cc5bcd84482 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_shade.json @@ -0,0 +1,50 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-07T23:01:15.966Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "refresh": {}, + "windowShade": { + "supportedWindowShadeCommands": { + "value": null + }, + "windowShade": { + "value": "open", + "timestamp": "2025-02-08T09:04:47.694Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/centralite.json b/tests/components/smartthings/fixtures/device_status/centralite.json new file mode 100644 index 00000000000..efdf54d9128 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/centralite.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 0.0, + "unit": "W", + "timestamp": "2025-02-09T17:49:15.190Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T17:49:15.112Z" + } + }, + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.783Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-01-26T10:19:54.788Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-01-26T10:19:54.789Z" + }, + "currentVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.775Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T17:24:16.864Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json new file mode 100644 index 00000000000..fa158d41b39 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -0,0 +1,66 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T17:16:42.674Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 59.0, + "unit": "F", + "timestamp": "2025-02-09T17:11:44.249Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T13:23:50.726Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "currentVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json new file mode 100644 index 00000000000..c80fcf9c298 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -0,0 +1,879 @@ +{ + "components": { + "1": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 0, + "unit": "%", + "timestamp": "2021-04-06T16:43:35.291Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + }, + "maximumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + }, + "airConditionerMode": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.686Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:57:57.602Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + }, + "acOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2021-04-06T16:44:10.518Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": null, + "timestamp": "2021-04-06T16:44:10.498Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnfv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "di": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "dmv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "n": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmo": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "vid": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmn": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "pi": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "icv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "audioVolume", + "custom.autoCleaningMode", + "custom.airConditionerTropicalNightMode", + "custom.airConditionerOdorController", + "demandResponseLoadControl", + "relativeHumidityMeasurement" + ], + "timestamp": "2024-09-10T10:26:28.605Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:44:10.325Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-08T00:44:53.247Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null, + "timestamp": "2021-04-06T16:44:10.373Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null, + "timestamp": "2021-04-06T16:43:59.136Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:54.748Z" + } + }, + "audioVolume": { + "volume": { + "value": null, + "unit": "%", + "timestamp": "2021-04-06T16:43:53.541Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2021-04-06T16:43:53.364Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": null, + "timestamp": "2021-04-06T16:43:53.344Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null, + "timestamp": "2021-04-06T16:43:38.992Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:39.097Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null, + "timestamp": "2021-04-06T16:43:38.843Z" + }, + "energySavingSupport": { + "value": null + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:38.529Z" + } + } + }, + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 60, + "unit": "%", + "timestamp": "2024-12-30T13:10:23.759Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-01-08T06:30:58.307Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2021-12-29T01:36:51.289Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_KRAC_18K", + "timestamp": "2025-02-08T00:44:53.855Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:43:37.208Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": ["off", "windFree"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T16:37:54.072Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:43:35.933Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:43:35.912Z" + }, + "mnfv": { + "value": "0.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "di": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:43:35.803Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "n": { + "value": "[room a/c] Samsung", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmo": { + "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "vid": { + "value": "DA-AC-RAC-000001", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnpv": { + "value": "0G3MPDCKA00010E", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnos": { + "value": "TizenRT2.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "pi": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "samsungce.dongleSoftwareInstallation", + "demandResponseLoadControl", + "custom.airConditionerOdorController" + ], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24070101, + "timestamp": "2024-09-04T06:35:09.557Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:43:35.782Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T09:14:39.249Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T16:33:29.164Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T09:15:11.608Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["1"], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 2247300, + "deltaEnergy": 400, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 2247300, + "energySaved": 0, + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.temperature"], + "if": ["oic.if.baseline", "oic.if.a"], + "range": [16.0, 30.0], + "units": "C", + "temperature": 22.0 + } + }, + "data": { + "href": "/temperature/desired/0" + }, + "timestamp": "2023-07-19T03:07:43.270Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-09-04T06:35:09.557Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T00:44:53.349Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-08T00:44:53.549Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:35.379Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2021-12-29T07:29:17.526Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CEZFTFFL7Z2", + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.363Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json new file mode 100644 index 00000000000..257d553cb9f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -0,0 +1,731 @@ +{ + "components": { + "main": { + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": ["custom.spiMode.setSpiMode"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 42, + "unit": "%", + "timestamp": "2025-02-09T17:02:45.042Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": [], + "timestamp": "2025-02-09T14:35:56.800Z" + }, + "supportedAcModes": { + "value": ["auto", "cool", "dry", "wind", "heat"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T05:44:01.853Z" + } + }, + "samsungce.airConditionerBeep": { + "beep": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARA-WW-TP1-22-COMMON_11240702", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "di": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "n": { + "value": "Samsung-Room-Air-Conditioner", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnmo": { + "value": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "vid": { + "value": "DA-AC-RAC-01001", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "pi": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "samsungce.deviceInfoPrivate", + "samsungce.quickControl", + "samsungce.welcomeCooling", + "samsungce.airConditionerBeep", + "samsungce.airConditionerLighting", + "samsungce.individualControlLock", + "samsungce.alwaysOnSensing", + "samsungce.buttonDisplayCondition", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "audioNotification" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100102, + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "010", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "vertical", "horizontal", "all"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "audioVolume": { + "volume": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 13836, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 13836, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-09T16:08:15Z", + "end": "2025-02-09T17:02:44Z" + }, + "timestamp": "2025-02-09T17:02:44.883Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "on", + "timestamp": "2025-02-09T05:44:02.014Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.welcomeCooling": { + "latestRequestId": { + "value": null + }, + "operatingState": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start", "cancel"], + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "errors": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterUsage": { + "value": 12, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-09T12:00:10.310Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2025-01-28T21:31:39.517Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.560Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-01-28T21:31:37.357Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.731Z" + } + }, + "bypassable": { + "bypassStatus": { + "value": "bypassed", + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "samsungce.airQualityHealthConcern": { + "supportedAirQualityHealthConcerns": { + "value": null + }, + "airQualityHealthConcern": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "otnDUID": { + "value": "U7CB2ZD4QPDUC", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-28T21:31:38.089Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "samsungce.silentAction": {}, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": 0, + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "airConditionerOdorControllerState": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 21, + "timestamp": "2025-01-28T21:31:35.935Z" + }, + "binaryId": { + "value": "ARA-WW-TP1-22-COMMON", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 6, + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "high", + "timestamp": "2025-02-09T14:07:45.816Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "samsungce.airConditionerLighting": { + "supportedLightingLevels": { + "value": ["on", "off"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lighting": { + "value": "on", + "timestamp": "2025-02-09T09:30:03.213Z" + } + }, + "samsungce.buttonDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:41.282Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-02-09T16:38:17.028Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-09T05:17:39.792Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:39.792Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 16, + "maximum": 30, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-02-09T05:17:41.533Z" + }, + "coolingSetpoint": { + "value": 23, + "unit": "C", + "timestamp": "2025-02-09T14:07:45.643Z" + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "alwaysOn": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "refresh": {}, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..181b62666c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json @@ -0,0 +1,600 @@ +{ + "components": { + "main": { + "doorControl": { + "door": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 30, + "timestamp": "2022-03-23T15:59:12.609Z" + }, + "defaultOvenMode": { + "value": "MicroWave", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-MICROWAVE-0101X", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T00:11:12.010Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "di": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2023-07-03T22:00:58.832Z" + }, + "n": { + "value": "Samsung Microwave", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnmo": { + "value": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "vid": { + "value": "DA-KS-MICROWAVE-0101X", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "pi": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-03-23T15:59:12.742Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "modelCode": { + "value": "ME8000T-/AA0", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "microwave", + "timestamp": "2022-03-23T15:59:10.971Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "MicroWave", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "100%", + "supportedValues": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ] + } + } + }, + { + "mode": "ConvectionBake", + "supportedOptions": { + "temperature": { + "F": { + "min": 100, + "max": 425, + "default": 350, + "supportedValues": [ + 100, 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOptions": { + "temperature": { + "F": { + "min": 200, + "max": 425, + "default": 325, + "supportedValues": [ + 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Grill", + "supportedOptions": { + "temperature": { + "F": { + "min": 425, + "max": 425, + "default": 425, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SpeedBake", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "SpeedRoast", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "KeepWarm", + "supportedOptions": { + "temperature": { + "F": { + "min": 175, + "max": 175, + "default": 175, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Autocook", + "supportedOptions": {} + }, + { + "mode": "Cookie", + "supportedOptions": { + "temperature": { + "F": { + "min": 325, + "max": 325, + "default": 325, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOptions": { + "operationTime": { + "max": "00:06:30" + } + } + } + ] + }, + "timestamp": "2025-02-08T10:21:03.790Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["doorControl", "samsungce.hoodFanSpeed"], + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22120101, + "timestamp": "2023-07-03T09:36:13.282Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "621", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 1, + "unit": "F", + "timestamp": "2025-02-09T00:11:15.291Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T21:13:36.188Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Microwave", + "ConvectionBake", + "ConvectionRoast", + "grill", + "Others", + "warming" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "MicroWave", + "ConvectionBake", + "ConvectionRoast", + "Grill", + "SpeedBake", + "SpeedRoast", + "KeepWarm", + "Autocook", + "Cookie", + "SteamClean" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2025-02-09T00:01:09.108Z" + } + }, + "refresh": {}, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-02-08T21:13:36.227Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ], + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "powerLevel": { + "value": "0%", + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.temperatures"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "1", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + "data": { + "href": "/temperatures/vs/0" + }, + "timestamp": "2023-07-19T05:50:12.609Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T21:13:36.357Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "U7CNQWBWSCD7C", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + } + } + }, + "hood": { + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "low", "high"], + "timestamp": "2025-02-08T21:13:36.289Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json new file mode 100644 index 00000000000..0c5a883b4f9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json @@ -0,0 +1,727 @@ +{ + "components": { + "pantry-01": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T13:55:01.720Z" + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-11-12T08:23:59.944Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode"], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 34, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 44, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 34, + "maximum": 44, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T14:48:16.247Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-23T04:42:18.178Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -8, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 5, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -8, + "maximum": 5, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 20, + "timestamp": "2024-11-08T01:09:17.382Z" + }, + "binaryId": { + "value": "TP2X_REF_20K", + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP2-21-COMMON_20220110", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "di": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "n": { + "value": "[refrigerator] Samsung", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmo": { + "value": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "vid": { + "value": "DA-REF-NORMAL-000001", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "pi": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "samsungce.dongleSoftwareInstallation", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.fridgeVacationMode", + "sec.diagnosticsInformation" + ], + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100101, + "timestamp": "2024-11-08T04:14:59.025Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-01-19T21:07:55.703Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-01-19T21:07:55.703Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker-02", + "pantry-01", + "pantry-02", + "cvroom", + "onedoor" + ], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-01-19T21:07:55.691Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": ["on", "off"], + "timestamp": "2025-01-19T21:07:55.799Z" + }, + "status": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.799Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1568087, + "deltaEnergy": 7, + "power": 6, + "powerEnergy": 13.555977778169844, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-09T17:38:01Z", + "end": "2025-02-09T17:49:00Z" + }, + "timestamp": "2025-02-09T17:49:00.507Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.rm.micomdata"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.rm.micomdata": "D0C0022B00000000000DFE15051F5AA54400000000000000000000000000000000000000000000000001F04A00C5E0", + "x.com.samsung.rm.micomdataLength": 94 + } + }, + "data": { + "href": "/rm/micomdata/vs/0" + }, + "timestamp": "2023-07-19T05:25:39.852Z" + } + }, + "refrigeration": { + "defrost": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.772Z" + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "drMaxDuration": { + "value": 1440, + "unit": "min", + "timestamp": "2022-02-07T11:39:47.504Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-02-07T11:39:47.504Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "otnDUID": { + "value": "P7CNQWBWM3XBW", + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": 1, + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": 100, + "timestamp": "2025-02-09T04:02:12.910Z" + }, + "waterFilterStatus": { + "value": "replace", + "timestamp": "2025-02-09T04:02:12.910Z" + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["temperatureMeasurement", "thermostatCoolingSetpoint"], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json new file mode 100644 index 00000000000..3bb2011a2b5 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json @@ -0,0 +1,274 @@ +{ + "components": { + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["station"], + "timestamp": "2020-11-03T04:43:07.114Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2020-11-03T04:43:07.092Z" + } + }, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "error", + "idle", + "charging", + "chargingForRemainingJob", + "paused", + "cleaning" + ], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "operatingState": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + }, + "cleaningStep": { + "value": null + }, + "homingReason": { + "value": "none", + "timestamp": "2020-11-03T04:43:22.926Z" + }, + "isMapBasedOperationAvailable": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2022-09-09T22:55:13.962Z" + }, + "type": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.alarms"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.code": "4", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.triggeredTime": "2023-06-18T15:59:30", + "x.com.samsung.da.state": "deleted" + } + ] + } + }, + "data": { + "href": "/alarms/vs/0" + }, + "timestamp": "2023-06-18T15:59:28.267Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2023-06-18T15:59:27.658Z" + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "off", + "timestamp": "2022-09-08T02:53:49.826Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-02T23:30:52.793Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-03T13:34:18.508Z" + }, + "mnfv": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "di": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnsl": { + "value": null, + "timestamp": "2020-06-03T00:49:53.813Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-12-23T07:09:40.610Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmo": { + "value": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "timestamp": "2022-09-07T06:42:36.551Z" + }, + "vid": { + "value": "DA-RVC-NORMAL-000001", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnpv": { + "value": "00", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnos": { + "value": "Tizen(3/0)", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "pi": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": ["auto", "spot", "manual", "stop"], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "repeatModeEnabled": { + "value": false, + "timestamp": "2020-12-21T01:32:56.245Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerMapAreaInfo", + "samsungce.robotCleanerMapCleaningInfo", + "samsungce.robotCleanerPatrol", + "samsungce.robotCleanerPetMonitoring", + "samsungce.robotCleanerPetMonitoringReport", + "samsungce.robotCleanerPetCleaningSchedule", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "samsungce.musicPlaylist", + "mediaPlayback", + "mediaTrackControl", + "imageCapture", + "videoCapture", + "audioVolume", + "audioMute", + "audioNotification", + "powerConsumptionReport", + "custom.hepaFilter", + "samsungce.robotCleanerMotorFilter", + "samsungce.robotCleanerRelayCleaning", + "audioTrackAddressing", + "samsungce.robotCleanerWelcome" + ], + "timestamp": "2022-09-08T01:03:48.820Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": null + }, + "newVersionAvailable": { + "value": null + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T09:26:07.107Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json new file mode 100644 index 00000000000..5535055f686 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json @@ -0,0 +1,786 @@ +{ + "components": { + "main": { + "samsungce.dishwasherWashingCourse": { + "customCourseCandidates": { + "value": null + }, + "washingCourse": { + "value": "normal", + "timestamp": "2025-02-08T20:21:26.497Z" + }, + "supportedCourses": { + "value": [ + "auto", + "normal", + "heavy", + "delicate", + "express", + "rinseOnly", + "selfClean" + ], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "dishwasherOperatingState": { + "completionTime": { + "value": "2025-02-08T22:49:26Z", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "progress": { + "value": null + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "dishwasherJobState": { + "value": "unknown", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.dishwasherWashingOptions": { + "dryPlus": { + "value": null + }, + "stormWash": { + "value": null + }, + "hotAirDry": { + "value": null + }, + "selectedZone": { + "value": { + "value": "all", + "settable": ["none", "upper", "lower", "all"] + }, + "timestamp": "2022-11-09T00:20:42.461Z" + }, + "speedBooster": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2023-11-24T14:46:55.375Z" + }, + "highTempWash": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-02-08T07:39:54.739Z" + }, + "sanitizingWash": { + "value": null + }, + "heatedDry": { + "value": null + }, + "zoneBooster": { + "value": { + "value": "none", + "settable": ["none", "left", "right", "all"] + }, + "timestamp": "2022-11-20T07:10:27.445Z" + }, + "addRinse": { + "value": null + }, + "supportedList": { + "value": [ + "selectedZone", + "zoneBooster", + "speedBooster", + "sanitize", + "highTempWash" + ], + "timestamp": "2021-06-27T01:19:38.000Z" + }, + "rinsePlus": { + "value": null + }, + "sanitize": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-01-18T23:49:09.964Z" + }, + "steamSoak": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DW_A51_20_COMMON", + "timestamp": "2025-02-08T19:29:30.987Z" + } + }, + "custom.dishwasherOperatingProgress": { + "dishwasherOperatingProgress": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T20:21:26.386Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DW_A51_20_COMMON_30230714", + "timestamp": "2023-11-02T15:58:55.699Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "di": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-07-04T13:53:32.032Z" + }, + "n": { + "value": "[dishwasher] Samsung", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmo": { + "value": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "vid": { + "value": "DA-WM-DW-000001", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "pi": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-06-27T01:19:37.615Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterConsumptionReport", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "sec.diagnosticsInformation", + "custom.waterFilter" + ], + "timestamp": "2025-02-08T19:29:32.447Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040105, + "timestamp": "2024-07-02T02:56:22.508Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.dishwasherOperation": { + "supportedOperatingState": { + "value": ["ready", "running", "paused"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "reservable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "progressPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "remainingTimeStr": { + "value": "02:28", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": 148.0, + "unit": "min", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "timeLeftToStart": { + "value": 0.0, + "unit": "min", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "samsungce.dishwasherJobState": { + "scheduledJobs": { + "value": [ + { + "jobName": "washing", + "timeInSec": 3600 + }, + { + "jobName": "rinsing", + "timeInSec": 1020 + }, + { + "jobName": "drying", + "timeInSec": 1200 + } + ], + "timestamp": "2025-02-08T20:21:26.928Z" + }, + "dishwasherJobState": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:00:37.450Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 101600, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-08T20:21:21Z", + "end": "2025-02-08T20:21:26Z" + }, + "timestamp": "2025-02-08T20:21:26.596Z" + } + }, + "refresh": {}, + "samsungce.dishwasherWashingCourseDetails": { + "predefinedCourses": { + "value": [ + { + "courseName": "auto", + "energyUsage": 3, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 60, + "unit": "C" + }, + "expectedTime": { + "time": 136, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "normal", + "energyUsage": 3, + "waterUsage": 4, + "temperature": { + "min": 45, + "max": 62, + "unit": "C" + }, + "expectedTime": { + "time": 148, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "heavy", + "energyUsage": 4, + "waterUsage": 5, + "temperature": { + "min": 65, + "max": 65, + "unit": "C" + }, + "expectedTime": { + "time": 155, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "delicate", + "energyUsage": 2, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 50, + "unit": "C" + }, + "expectedTime": { + "time": 112, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "express", + "energyUsage": 2, + "waterUsage": 2, + "temperature": { + "min": 52, + "max": 52, + "unit": "C" + }, + "expectedTime": { + "time": 60, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "rinseOnly", + "energyUsage": 1, + "waterUsage": 1, + "temperature": { + "min": 40, + "max": 40, + "unit": "C" + }, + "expectedTime": { + "time": 14, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "selfClean", + "energyUsage": 5, + "waterUsage": 4, + "temperature": { + "min": 70, + "max": 70, + "unit": "C" + }, + "expectedTime": { + "time": 139, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "all"] + } + } + } + ], + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "waterUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "energyUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.operational.state"], + "if": ["oic.if.baseline", "oic.if.a"], + "currentMachineState": "idle", + "machineStates": ["pause", "active", "idle"], + "jobStates": [ + "None", + "Predrain", + "Prewash", + "Wash", + "Rinse", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:16:00", + "progressPercentage": "1" + } + }, + "data": { + "href": "/operational/state/0" + }, + "timestamp": "2023-07-19T04:23:15.606Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.dishwasherOperatingPercentage": { + "dishwasherOperatingPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:00:37.555Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": null + }, + "supportedCourses": { + "value": ["82", "83", "84", "85", "86", "87", "88"], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "custom.dishwasherDelayStartTime": { + "dishwasherDelayStartTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2023-08-25T03:23:06.667Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-10-01T00:08:09.813Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "MTCNQWBWIV6TS", + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2022-07-20T03:37:30.706Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json new file mode 100644 index 00000000000..fe43b490387 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json @@ -0,0 +1,719 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedModes": { + "value": ["normal", "timeDry", "quickDry"], + "timestamp": "2025-02-08T18:10:10.497Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.840Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": "medium", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryingTemperature": { + "value": ["none", "extraLow", "low", "mediumLow", "medium", "high"], + "timestamp": "2025-01-04T22:52:14.884Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T06:49:02.183Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-08T18:10:10.990Z" + }, + "presets": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3000000100111100020B000000000000", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-02-08T18:10:11.113Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.911Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "di": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "pi": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "normal", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "damp", "less", "normal", "more", "very"], + "timestamp": "2021-06-01T22:54:28.224Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-08T18:10:11.986Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "01", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "9C", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "9E", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8308", + "default": "mediumLow", + "options": ["mediumLow"] + } + } + }, + { + "cycle": "9B", + "supportedOptions": { + "dryingLevel": { + "raw": "D520", + "default": "very", + "options": ["very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "E5", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A0", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A4", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "853E", + "default": "high", + "options": ["extraLow", "low", "mediumLow", "medium", "high"] + } + } + }, + { + "cycle": "A6", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A3", + "supportedOptions": { + "dryingLevel": { + "raw": "D308", + "default": "normal", + "options": ["normal"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "A2", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8102", + "default": "extraLow", + "options": ["extraLow"] + } + } + } + ], + "timestamp": "2025-01-04T22:52:14.884Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-05T16:04:06.674Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:59:11.115Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:10:10.825Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4495500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T04:00:19Z", + "end": "2025-02-08T18:10:11Z" + }, + "timestamp": "2025-02-08T18:10:11.053Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-08T19:25:10Z", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:54:28.372Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "x.com.samsung.da.serialNum": "FFFFFFFFFFFFFFF", + "x.com.samsung.da.otnDUID": "7XCDM6YAIRCGM", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20112625", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T22:48:43.192Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:10:10.970Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCourses": { + "value": [ + "01", + "9C", + "A5", + "9E", + "9B", + "27", + "E5", + "A0", + "A4", + "A6", + "A3", + "A2" + ], + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T06:49:02.183Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T06:49:02.721Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T13:43:26.961Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 57 + }, + { + "jobName": "cooling", + "timeInMin": 3 + } + ], + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTimeStr": { + "value": "01:15", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTime": { + "value": 75, + "unit": "min", + "timestamp": "2025-02-07T04:00:18.186Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "7XCDM6YAIRCGM", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "20", "30", "40", "50", "60"], + "timestamp": "2021-06-01T22:54:28.224Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-02-08T18:10:10.840Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json new file mode 100644 index 00000000000..6a141c9462e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json @@ -0,0 +1,1243 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedModes": { + "value": ["normal", "quickWash"], + "timestamp": "2025-02-07T02:29:55.152Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-07T02:29:55.546Z" + }, + "minimumReservableTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null + }, + "waterLevel": { + "value": null + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "tapCold", "cold", "warm", "hot", "extraHot"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerWaterTemperature": { + "value": "warm", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-15T14:11:34.909Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "2001000100131100022B010000000000", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "description": { + "value": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_TP2_20_COMMON", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null + }, + "supportedWaterValve": { + "value": null + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-02-07T03:54:45Z", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-07T02:29:55.546Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-07T03:09:45.456Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "01", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43B", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "833E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "70", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "hot", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "55", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "71", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A20F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "72", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "77", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A21F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium", "high"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "E5", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "57", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A520", + "default": "extraHigh", + "options": ["extraHigh"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "73", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "74", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A207", + "default": "low", + "options": ["rinseHold", "noSpin", "low"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "75", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "medium", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "810E", + "default": "tapCold", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "78", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C13E", + "default": "extraLight", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + } + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-06-01T22:52:20.068Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_TP2_20_COMMON_30230804", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "di": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmo": { + "value": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "pi": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": null + }, + "supportedDryerDryLevel": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerBubbleSoak", + "samsungce.washerFreezePrevent", + "custom.dryerDryLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener" + ], + "timestamp": "2024-07-01T16:13:35.173Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:14:52.963Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "210", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-04T14:21:57.546Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 23 + }, + { + "jobName": "rinse", + "timeInMin": 10 + }, + { + "jobName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 23 + }, + { + "phaseName": "rinse", + "timeInMin": 10 + }, + { + "phaseName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "remainingTimeStr": { + "value": "00:45", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobPhase": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operationTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + }, + "remainingTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-07T02:29:55.407Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 352800, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T03:09:24Z", + "end": "2025-02-07T03:09:45Z" + }, + "timestamp": "2025-02-07T03:09:45.703Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null + }, + "orderThreshold": { + "value": null + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": [ + "none", + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerSoilLevel": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": null + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-07T02:29:55.805Z" + }, + "presets": { + "value": null + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:52:19.999Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "x.com.samsung.da.serialNum": "01FW57AR401623N", + "x.com.samsung.da.otnDUID": "U7CNQWBWJM5U4", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A220725(F541)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20050607", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T16:52:15.994Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null + }, + "dosage": { + "value": null + }, + "softenerType": { + "value": null + }, + "initialAmount": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-07T02:29:55.634Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedCourses": { + "value": [ + "01", + "70", + "55", + "71", + "72", + "77", + "E5", + "57", + "73", + "74", + "75", + "78" + ], + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null + }, + "washingTime": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-15T14:11:34.909Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-15T14:26:38.584Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2022-06-15T14:11:37.255Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-06-15T14:11:37.255Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "otnDUID": { + "value": "U7CNQWBWJM5U4", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T23:36:22.798Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "high", + "timestamp": "2025-02-07T02:29:55.691Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json new file mode 100644 index 00000000000..e9d8addfcb3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json @@ -0,0 +1,51 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "not present", + "timestamp": "2025-02-11T13:58:50.044Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.471Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T14:23:22.053Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:36:16.823Z" + } + }, + "refresh": {}, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-11T13:58:50.044Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json new file mode 100644 index 00000000000..dd4b8717195 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json @@ -0,0 +1,98 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 32, + "unit": "%", + "timestamp": "2025-02-11T14:36:17.275Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.448Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:23:21.556Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "auto", + "data": { + "supportedThermostatFanModes": ["on", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatFanModes": { + "value": ["on", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "cool", "auxheatonly", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatModes": { + "value": ["off", "cool", "auxheatonly", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 73, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/fake_fan.json b/tests/components/smartthings/fixtures/device_status/fake_fan.json new file mode 100644 index 00000000000..91efb69cee6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/fake_fan.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 60, + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..bff74f135be --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json @@ -0,0 +1,23 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 39, + "unit": "%", + "timestamp": "2025-02-07T02:39:25.819Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hub.json b/tests/components/smartthings/fixtures/device_status/hub.json new file mode 100644 index 00000000000..98ff4c3a8b4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hub.json @@ -0,0 +1,3 @@ +{ + "components": {} +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..6bdf7ceb2dd --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json @@ -0,0 +1,75 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.671Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.823Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..5868472267c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json @@ -0,0 +1,94 @@ +{ + "components": { + "main": { + "colorControl": { + "saturation": { + "value": 60, + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "color": { + "value": null + }, + "hue": { + "value": 60.8072, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.678Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "samsungim.hueSyncMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T07:08:19.519Z" + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-06T15:14:52.807Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/iphone.json b/tests/components/smartthings/fixtures/device_status/iphone.json new file mode 100644 index 00000000000..618ce440ff0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/iphone.json @@ -0,0 +1,12 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "present", + "timestamp": "2023-09-22T18:12:25.012Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json new file mode 100644 index 00000000000..e0b37de7e3c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json @@ -0,0 +1,79 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-08T14:00:28.332Z" + } + }, + "threeAxis": { + "threeAxis": { + "value": [20, 8, -1042], + "unit": "mG", + "timestamp": "2025-02-09T17:27:36.673Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 67.0, + "unit": "F", + "timestamp": "2025-02-09T17:56:19.744Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 50, + "unit": "%", + "timestamp": "2025-02-09T12:24:02.074Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T04:20:25.601Z" + }, + "currentVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.593Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "accelerationSensor": { + "acceleration": { + "value": "inactive", + "timestamp": "2025-02-09T17:27:46.812Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..b4263e7eb87 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json @@ -0,0 +1,57 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-12-04T10:10:02.934Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "refresh": {}, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20, + "unit": "C", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T10:09:47.758Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/smart_plug.json b/tests/components/smartthings/fixtures/device_status/smart_plug.json new file mode 100644 index 00000000000..f4f591483c6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/smart_plug.json @@ -0,0 +1,43 @@ +{ + "components": { + "main": { + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-08T19:37:03.622Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "currentVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.594Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:31:12.210Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sonos_player.json b/tests/components/smartthings/fixtures/device_status/sonos_player.json new file mode 100644 index 00000000000..057b6c62d0d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sonos_player.json @@ -0,0 +1,259 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-02T13:18:40.078Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-02-09T19:53:58.330Z" + } + }, + "mediaPresets": { + "presets": { + "value": [ + { + "id": "10", + "imageUrl": "https://www.storytel.com//images/320x320/0000059036.jpg", + "mediaSource": "Storytel", + "name": "Dra \u00e5t skogen Sune!" + }, + { + "id": "22", + "imageUrl": "https://www.storytel.com//images/320x320/0000001894.jpg", + "mediaSource": "Storytel", + "name": "Fy katten Sune" + }, + { + "id": "29", + "imageUrl": "https://www.storytel.com//images/320x320/0000001896.jpg", + "mediaSource": "Storytel", + "name": "Gult \u00e4r fult, Sune" + }, + { + "id": "2", + "imageUrl": "https://static.mytuner.mobi/media/tvos_radios/2l5zg6lhjbab.png", + "mediaSource": "myTuner Radio", + "name": "Kiss" + }, + { + "id": "3", + "imageUrl": "https://www.storytel.com//images/320x320/0000046017.jpg", + "mediaSource": "Storytel", + "name": "L\u00e4skigt Sune!" + }, + { + "id": "16", + "imageUrl": "https://www.storytel.com//images/320x320/0002590598.jpg", + "mediaSource": "Storytel", + "name": "Pluggh\u00e4sten Sune" + }, + { + "id": "14", + "imageUrl": "https://www.storytel.com//images/320x320/0000000070.jpg", + "mediaSource": "Storytel", + "name": "Sagan om Sune" + }, + { + "id": "18", + "imageUrl": "https://www.storytel.com//images/320x320/0000006452.jpg", + "mediaSource": "Storytel", + "name": "Sk\u00e4mtaren Sune" + }, + { + "id": "26", + "imageUrl": "https://www.storytel.com//images/320x320/0000001892.jpg", + "mediaSource": "Storytel", + "name": "Spik och panik, Sune!" + }, + { + "id": "7", + "imageUrl": "https://www.storytel.com//images/320x320/0003119145.jpg", + "mediaSource": "Storytel", + "name": "Sune - T\u00e5gsemestern" + }, + { + "id": "25", + "imageUrl": "https://www.storytel.com//images/320x320/0000000071.jpg", + "mediaSource": "Storytel", + "name": "Sune b\u00f6rjar tv\u00e5an" + }, + { + "id": "9", + "imageUrl": "https://www.storytel.com//images/320x320/0000006448.jpg", + "mediaSource": "Storytel", + "name": "Sune i Grekland" + }, + { + "id": "8", + "imageUrl": "https://www.storytel.com//images/320x320/0002492498.jpg", + "mediaSource": "Storytel", + "name": "Sune i Ullared" + }, + { + "id": "30", + "imageUrl": "https://www.storytel.com//images/320x320/0002072946.jpg", + "mediaSource": "Storytel", + "name": "Sune och familjen Anderssons sjuka jul" + }, + { + "id": "17", + "imageUrl": "https://www.storytel.com//images/320x320/0000000475.jpg", + "mediaSource": "Storytel", + "name": "Sune och klantpappan" + }, + { + "id": "11", + "imageUrl": "https://www.storytel.com//images/320x320/0000042688.jpg", + "mediaSource": "Storytel", + "name": "Sune och Mamma Mysko" + }, + { + "id": "20", + "imageUrl": "https://www.storytel.com//images/320x320/0000000072.jpg", + "mediaSource": "Storytel", + "name": "Sune och syster vampyr" + }, + { + "id": "15", + "imageUrl": "https://www.storytel.com//images/320x320/0000039918.jpg", + "mediaSource": "Storytel", + "name": "Sune slutar f\u00f6rsta klass" + }, + { + "id": "5", + "imageUrl": "https://www.storytel.com//images/320x320/0000017431.jpg", + "mediaSource": "Storytel", + "name": "Sune v\u00e4rsta killen!" + }, + { + "id": "27", + "imageUrl": "https://www.storytel.com//images/320x320/0000068900.jpg", + "mediaSource": "Storytel", + "name": "Sunes halloween" + }, + { + "id": "19", + "imageUrl": "https://www.storytel.com//images/320x320/0000000476.jpg", + "mediaSource": "Storytel", + "name": "Sunes hemligheter" + }, + { + "id": "21", + "imageUrl": "https://www.storytel.com//images/320x320/0002370989.jpg", + "mediaSource": "Storytel", + "name": "Sunes hj\u00e4rnsl\u00e4pp" + }, + { + "id": "24", + "imageUrl": "https://www.storytel.com//images/320x320/0000001889.jpg", + "mediaSource": "Storytel", + "name": "Sunes jul" + }, + { + "id": "28", + "imageUrl": "https://www.storytel.com//images/320x320/0000034437.jpg", + "mediaSource": "Storytel", + "name": "Sunes party" + }, + { + "id": "4", + "imageUrl": "https://www.storytel.com//images/320x320/0000006450.jpg", + "mediaSource": "Storytel", + "name": "Sunes skolresa" + }, + { + "id": "13", + "imageUrl": "https://www.storytel.com//images/320x320/0000000477.jpg", + "mediaSource": "Storytel", + "name": "Sunes sommar" + }, + { + "id": "12", + "imageUrl": "https://www.storytel.com//images/320x320/0000046015.jpg", + "mediaSource": "Storytel", + "name": "Sunes Sommarstuga" + }, + { + "id": "6", + "imageUrl": "https://www.storytel.com//images/320x320/0002099327.jpg", + "mediaSource": "Storytel", + "name": "Supersnuten Sune" + }, + { + "id": "23", + "imageUrl": "https://www.storytel.com//images/320x320/0000563738.jpg", + "mediaSource": "Storytel", + "name": "Zunes stolpskott" + } + ], + "timestamp": "2025-02-02T13:18:48.272Z" + } + }, + "audioVolume": { + "volume": { + "value": 15, + "unit": "%", + "timestamp": "2025-02-09T19:57:37.230Z" + } + }, + "mediaGroup": { + "groupMute": { + "value": "unmuted", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupPrimaryDeviceId": { + "value": "RINCON_38420B9108F601400", + "timestamp": "2025-02-09T19:52:24.000Z" + }, + "groupId": { + "value": "RINCON_38420B9108F601400:3579458382", + "timestamp": "2025-02-09T19:54:06.936Z" + }, + "groupVolume": { + "value": 12, + "unit": "%", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupRole": { + "value": "ungrouped", + "timestamp": "2025-02-09T19:52:23.974Z" + } + }, + "refresh": {}, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": ["nextTrack", "previousTrack"], + "timestamp": "2025-02-02T13:18:40.123Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T19:57:35.487Z" + } + }, + "audioNotification": {}, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": { + "album": "Forever Young", + "albumArtUrl": "http://192.168.1.123:1400/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a3bg2qahpZmsg5wV2EMPXIk%3fsid%3d9%26flags%3d8232%26sn%3d9", + "artist": "David Guetta", + "mediaSource": "Spotify", + "title": "Forever Young" + }, + "timestamp": "2025-02-09T19:53:55.615Z" + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json new file mode 100644 index 00000000000..a0bcbd742f4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json @@ -0,0 +1,164 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-09T15:42:12.923Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-02-09T15:42:12.923Z" + } + }, + "samsungvd.soundFrom": { + "mode": { + "value": 3, + "timestamp": "2025-02-09T15:42:13.215Z" + }, + "detailName": { + "value": "External Device", + "timestamp": "2025-02-09T15:42:13.215Z" + } + }, + "audioVolume": { + "volume": { + "value": 17, + "unit": "%", + "timestamp": "2025-02-09T17:25:51.839Z" + } + }, + "samsungvd.audioGroupInfo": { + "role": { + "value": null + }, + "status": { + "value": null + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["digital", "HDMI1", "bluetooth", "wifi", "HDMI2"], + "timestamp": "2025-02-09T17:18:44.680Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2025-02-09T17:18:44.680Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:25:51.536Z" + } + }, + "ocf": { + "st": { + "value": "2024-12-10T02:12:44Z", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnfv": { + "value": "SAT-iMX8M23WWC-1010.5", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnhw": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "di": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnsl": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "n": { + "value": "Soundbar Living", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmo": { + "value": "HW-Q990C", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "vid": { + "value": "VD-NetworkAudio-002S", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnml": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnpv": { + "value": "7.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "pi": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T17:18:44.787Z" + } + }, + "samsungvd.thingStatus": { + "updatedTime": { + "value": 1739115734, + "timestamp": "2025-02-09T15:42:13.949Z" + }, + "status": { + "value": "Idle", + "timestamp": "2025-02-09T15:42:13.949Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "audioTrackData": { + "value": { + "title": "", + "artist": "", + "album": "" + }, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "elapsedTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.828Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json new file mode 100644 index 00000000000..18496942e2f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json @@ -0,0 +1,266 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop", "fastForward", "rewind"], + "timestamp": "2020-05-07T02:58:10.250Z" + }, + "playbackStatus": { + "value": null, + "timestamp": "2020-08-04T21:53:22.108Z" + } + }, + "audioVolume": { + "volume": { + "value": 13, + "unit": "%", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "samsungvd.supportsPowerOnByOcf": { + "supportsPowerOnByOcf": { + "value": null, + "timestamp": "2020-10-29T10:47:20.305Z" + } + }, + "samsungvd.mediaInputSource": { + "supportedInputSourcesMap": { + "value": [ + { + "id": "dtv", + "name": "TV" + }, + { + "id": "HDMI1", + "name": "PlayStation 4" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + } + ], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["digitalTv", "HDMI1", "HDMI4", "HDMI4"], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "custom.tvsearch": {}, + "samsungvd.ambient": {}, + "refresh": {}, + "custom.error": { + "error": { + "value": null, + "timestamp": "2020-08-04T21:53:22.148Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.tv.deviceinfo"], + "if": ["oic.if.baseline", "oic.if.r"], + "x.com.samsung.country": "USA", + "x.com.samsung.infolinkversion": "T-INFOLINK2017-1008", + "x.com.samsung.modelid": "17_KANTM_UHD", + "x.com.samsung.tv.blemac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.btmac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.category": "tv", + "x.com.samsung.tv.countrycode": "US", + "x.com.samsung.tv.duid": "B2NBQRAG357IX", + "x.com.samsung.tv.ethmac": "c0:48:e6:e7:fc:2c", + "x.com.samsung.tv.p2pmac": "ce:6e:a4:1f:4c:f6", + "x.com.samsung.tv.udn": "717fb7ed-b310-4cfe-8954-1cd8211dd689", + "x.com.samsung.tv.wifimac": "cc:6e:a4:1f:4c:f6" + } + }, + "data": { + "href": "/sec/tv/deviceinfo" + }, + "timestamp": "2021-08-30T19:18:12.303Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2021-10-16T15:18:11.317Z" + } + }, + "tvChannel": { + "tvChannel": { + "value": "", + "timestamp": "2020-05-07T02:58:10.479Z" + }, + "tvChannelName": { + "value": "", + "timestamp": "2021-08-21T18:53:06.643Z" + } + }, + "ocf": { + "st": { + "value": "2021-08-21T14:50:34Z", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mndt": { + "value": "2017-01-01", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mnfv": { + "value": "T-KTMAKUC-1290.3", + "timestamp": "2021-08-21T18:52:57.543Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "di": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/tv/overview/", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + }, + "n": { + "value": "[TV] Samsung 8 Series (49)", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmo": { + "value": "UN49MU8000", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "vid": { + "value": "VD-STV_2017_K", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnpv": { + "value": "Tizen 3.0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "pi": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + } + }, + "custom.picturemode": { + "pictureMode": { + "value": "Dynamic", + "timestamp": "2020-12-23T01:33:37.069Z" + }, + "supportedPictureModes": { + "value": ["Dynamic", "Standard", "Natural", "Movie"], + "timestamp": "2020-05-07T02:58:10.585Z" + }, + "supportedPictureModesMap": { + "value": [ + { + "id": "modeDynamic", + "name": "Dynamic" + }, + { + "id": "modeStandard", + "name": "Standard" + }, + { + "id": "modeNatural", + "name": "Natural" + }, + { + "id": "modeMovie", + "name": "Movie" + } + ], + "timestamp": "2020-12-23T01:33:37.069Z" + } + }, + "samsungvd.ambientContent": { + "supportedAmbientApps": { + "value": [], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.accessibility": {}, + "custom.recording": {}, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungvd.ambient", "samsungvd.ambientContent"], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.soundmode": { + "supportedSoundModesMap": { + "value": [ + { + "id": "modeStandard", + "name": "Standard" + } + ], + "timestamp": "2021-08-21T19:19:52.887Z" + }, + "soundMode": { + "value": "Standard", + "timestamp": "2020-12-23T01:33:37.272Z" + }, + "supportedSoundModes": { + "value": ["Standard"], + "timestamp": "2021-08-21T19:19:52.887Z" + } + }, + "audioMute": { + "mute": { + "value": "muted", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null, + "timestamp": "2020-08-04T21:53:22.384Z" + } + }, + "custom.launchapp": {}, + "samsungvd.firmwareVersion": { + "firmwareVersion": { + "value": null, + "timestamp": "2020-10-29T10:47:19.376Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json new file mode 100644 index 00000000000..c2c36fa249e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json @@ -0,0 +1,97 @@ +{ + "components": { + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "pending cool", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 814.7469111058201, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "heatingSetpointRange": { + "value": { + "maximum": 3226.693210895862, + "step": 9234.459191378826, + "minimum": 6214.940743832475 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "maximum": 1826.722761785079, + "step": 138.2080712609211, + "minimum": 9268.726934158902 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "temperature": { + "value": 8554.194688973037, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "followschedule", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatFanModes": { + "value": ["on"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "auxheatonly", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatModes": { + "value": ["rush hour"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "battery": { + "quantity": { + "value": 51, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "type": { + "value": "38140", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "maximum": 7288.145606306409, + "step": 7620.031701049315, + "minimum": 4997.721228739137 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "coolingSetpoint": { + "value": 244.33726326608746, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_valve.json b/tests/components/smartthings/fixtures/device_status/virtual_valve.json new file mode 100644 index 00000000000..8cb66c72595 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_valve.json @@ -0,0 +1,13 @@ +{ + "components": { + "main": { + "refresh": {}, + "valve": { + "valve": { + "value": "closed", + "timestamp": "2025-02-11T11:27:02.262Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json new file mode 100644 index 00000000000..8200bfe81a1 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json @@ -0,0 +1,28 @@ +{ + "components": { + "main": { + "waterSensor": { + "water": { + "value": "dry", + "timestamp": "2025-02-10T21:58:18.784Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": 84, + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "type": { + "value": "46120", + "timestamp": "2025-02-10T21:58:18.784Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..0bb1af96f70 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json @@ -0,0 +1,110 @@ +{ + "components": { + "main": { + "lock": { + "supportedUnlockDirections": { + "value": null + }, + "supportedLockValues": { + "value": null + }, + "lock": { + "value": "locked", + "data": {}, + "timestamp": "2025-02-09T17:29:56.641Z" + }, + "supportedLockCommands": { + "value": null + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 86, + "unit": "%", + "timestamp": "2025-02-09T17:18:14.150Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T11:48:45.332Z" + }, + "currentVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.328Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "lockCodes": { + "codeLength": { + "value": null, + "timestamp": "2020-08-04T15:29:24.127Z" + }, + "maxCodes": { + "value": 250, + "timestamp": "2023-08-22T01:34:19.751Z" + }, + "maxCodeLength": { + "value": 8, + "timestamp": "2023-08-22T01:34:18.690Z" + }, + "codeChanged": { + "value": "8 unset", + "data": { + "codeName": "Code 8" + }, + "timestamp": "2025-01-06T04:56:31.712Z" + }, + "lock": { + "value": "locked", + "data": { + "method": "manual" + }, + "timestamp": "2023-07-10T23:03:42.305Z" + }, + "minCodeLength": { + "value": 4, + "timestamp": "2023-08-22T01:34:18.781Z" + }, + "codeReport": { + "value": 5, + "timestamp": "2022-08-01T01:36:58.424Z" + }, + "scanCodes": { + "value": "Complete", + "timestamp": "2025-01-06T04:56:31.730Z" + }, + "lockCodes": { + "value": "{\"1\":\"Salim\",\"2\":\"Saima\",\"3\":\"Sarah\",\"4\":\"Aisha\",\"5\":\"Moiz\"}", + "timestamp": "2025-01-06T04:56:28.325Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..ab2fe41c678 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "deviceId": "f0af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "aeotec-home-energy-meter-gen5", + "label": "Aeotec Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "3e0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6911ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "93257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "label": "Meter", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "voltageMeasurement", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "profile": { + "id": "6372c227-93c7-32ef-9be5-aef2221adff1" + }, + "zwave": { + "networkId": "0A", + "driverId": "b98b34ce-1d1d-480c-bb17-41307a90cde0", + "executingLocally": true, + "hubId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "networkSecurityLevel": "ZWAVE_S0_LEGACY", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 95 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json new file mode 100644 index 00000000000..a81ca788b29 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -0,0 +1,61 @@ +{ + "items": [ + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "name": "base-electric-meter", + "label": "Aeon Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8e619cd9-c271-3ba0-9015-62bc074bc47f", + "deviceManufacturerCode": "0086-0002-0009", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-03T16:23:57.284Z", + "profile": { + "id": "d382796f-8ed5-3088-8735-eb03e962203b" + }, + "zwave": { + "networkId": "2A", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 9 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..21d4d475e7a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + "name": "c2c-arlo-pro-3-switch", + "label": "2nd Floor Hallway", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c_arlo_pro_3", + "deviceManufacturerCode": "Arlo", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "soundSensor", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "videoStream", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "alarm", + "version": 1 + } + ], + "categories": [ + { + "name": "Camera", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-21T21:55:59.340Z", + "profile": { + "id": "89aefc3a-e210-4678-944c-638d47d296f6" + }, + "viper": { + "manufacturerName": "Arlo", + "modelName": "VMC4041PB", + "endpointAppId": "viper_555d6f40-b65a-11ea-8fe0-77cb99571462" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_shade.json b/tests/components/smartthings/fixtures/devices/c2c_shade.json new file mode 100644 index 00000000000..265eab11ff5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_shade.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "571af102-15db-4030-b76b-245a691f74a5", + "name": "c2c-shade", + "label": "Curtain 1A", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c-shade", + "deviceManufacturerCode": "WonderLabs Company", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-07T23:01:15.883Z", + "profile": { + "id": "0ceffb3e-10d3-4123-bb42-2a92c93c6e25" + }, + "viper": { + "manufacturerName": "WonderLabs Company", + "modelName": "WoCurtain3", + "hwVersion": "WoCurtain3-WoCurtain3", + "endpointAppId": "viper_f18eb770-077d-11ea-bb72-9922e3ed0d38" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json new file mode 100644 index 00000000000..d94043efbc8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -0,0 +1,66 @@ +{ + "items": [ + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "name": "plug-level-power", + "label": "Dimmer Debian", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "bb7c4cfb-6eaf-3efc-823b-06a54fc9ded9", + "deviceManufacturerCode": "CentraLite", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-08-15T22:16:37.926Z", + "profile": { + "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" + }, + "zigbee": { + "eui": "000D6F0003C04BC9", + "networkId": "F50E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json new file mode 100644 index 00000000000..68070abbfc3 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -0,0 +1,70 @@ +{ + "items": [ + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "name": "contact-profile", + "label": ".Front Door Open/Closed Sensor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "a7f2c1d9-89b3-35a4-b217-fc68d9e4e752", + "deviceManufacturerCode": "Visonic", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "ContactSensor", + "categoryType": "manufacturer" + }, + { + "name": "ContactSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2023-09-28T17:38:59.179Z", + "profile": { + "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" + }, + "zigbee": { + "eui": "000D6F000576F604", + "networkId": "5A44", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json new file mode 100644 index 00000000000..d831e15a86b --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -0,0 +1,311 @@ +{ + "items": [ + { + "deviceId": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "name": "[room a/c] Samsung", + "label": "AC Office Granit", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", + "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "1", + "label": "1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-04-06T16:43:34.753Z", + "profile": { + "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "[room a/c] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "platformVersion": "0G3MPDCKA00010E", + "platformOS": "TizenRT2.0", + "hwVersion": "1.0", + "firmwareVersion": "0.1.0", + "vendorId": "DA-AC-RAC-000001", + "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json new file mode 100644 index 00000000000..db6f8d09673 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -0,0 +1,264 @@ +{ + "items": [ + { + "deviceId": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "name": "Samsung-Room-Air-Conditioner", + "label": "Aire Dormitorio Principal", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "bypassable", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.airConditionerBeep", + "version": 1 + }, + { + "id": "samsungce.airConditionerLighting", + "version": 1 + }, + { + "id": "samsungce.airQualityHealthConcern", + "version": 1 + }, + { + "id": "samsungce.buttonDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.silentAction", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.welcomeCooling", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-28T21:31:35.755Z", + "profile": { + "id": "091a55f4-7054-39fa-b23e-b56deb7580f8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung-Room-Air-Conditioner", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "ARA-WW-TP1-22-COMMON_11240702", + "vendorId": "DA-AC-RAC-01001", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2025-01-28T21:31:30.090416369Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..f636b069e38 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -0,0 +1,176 @@ +{ + "items": [ + { + "deviceId": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "name": "Samsung Microwave", + "label": "Microwave", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-MICROWAVE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "oic.d.microwave", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "doorControl", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Microwave", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-03-23T15:59:10.704Z", + "profile": { + "id": "e5db3b6f-cad6-3caa-9775-9c9cae20f4a4" + }, + "ocf": { + "ocfDeviceType": "oic.d.microwave", + "name": "Samsung Microwave", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "vendorId": "DA-KS-MICROWAVE-0101X", + "vendorResourceClientServerVersion": "MediaTek Release 2.220916.2", + "lastSignupTime": "2022-04-17T15:33:11.063457Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json new file mode 100644 index 00000000000..29372cac23c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -0,0 +1,412 @@ +{ + "items": [ + { + "deviceId": "7db87911-7dce-1cf2-7119-b953432a2f09", + "name": "[refrigerator] Samsung", + "label": "Refrigerator", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + }, + { + "name": "Refrigerator", + "categoryType": "user" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-01-08T16:50:43.544Z", + "profile": { + "id": "f2a9af35-5df8-3477-91df-94941d302591" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "[refrigerator] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "A-RFWW-TP2-21-COMMON_20220110", + "vendorId": "DA-REF-NORMAL-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.210524.1", + "lastSignupTime": "2024-08-06T15:24:29.362093Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json new file mode 100644 index 00000000000..b7f8ab2a42c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -0,0 +1,119 @@ +{ + "items": [ + { + "deviceId": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "Robot vacuum", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-06-06T23:04:25Z", + "profile": { + "id": "61b1c3cd-61cc-3dde-a4ba-9477d5e559cb" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "platformVersion": "00", + "platformOS": "Tizen(3/0)", + "hwVersion": "1.0", + "firmwareVersion": "1.0", + "vendorId": "DA-RVC-NORMAL-000001", + "lastSignupTime": "2020-11-03T04:43:02.729Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json new file mode 100644 index 00000000000..33392081bf5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -0,0 +1,168 @@ +{ + "items": [ + { + "deviceId": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "name": "[dishwasher] Samsung", + "label": "Dishwasher", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-DW-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Dishwasher", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "dishwasherOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingProgress", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingPercentage", + "version": 1 + }, + { + "id": "custom.dishwasherDelayStartTime", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dishwasherJobState", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourse", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourseDetails", + "version": 1 + }, + { + "id": "samsungce.dishwasherOperation", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingOptions", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dishwasher", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-27T01:19:35.408Z", + "profile": { + "id": "0cba797c-40ee-3473-aa01-4ee5b6cb8c67" + }, + "ocf": { + "ocfDeviceType": "oic.d.dishwasher", + "name": "[dishwasher] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_DW_A51_20_COMMON_30230714", + "vendorId": "DA-WM-DW-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-10-16T17:28:59.984202Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json new file mode 100644 index 00000000000..ef47260a989 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -0,0 +1,204 @@ +{ + "items": [ + { + "deviceId": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "name": "[dryer] Samsung", + "label": "Dryer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:54:25.907Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-06-01T22:54:22.826697Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json new file mode 100644 index 00000000000..4996eebab96 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -0,0 +1,260 @@ +{ + "items": [ + { + "deviceId": "f984b91d-f250-9d42-3436-33f09a422a47", + "name": "[washer] Samsung", + "label": "Washer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:52:18.023Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_WM_TP2_20_COMMON_30230804", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2021-06-01T22:52:13.923649Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_sensor.json b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json new file mode 100644 index 00000000000..4c37a17f1a0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "d5dc3299-c266-41c7-bd08-f540aea54b89", + "name": "ecobee Sensor", + "label": "Child Bedroom", + "manufacturerName": "0A0b", + "presentationId": "ST_635a866e-a3ea-4184-9d60-9c72ea603dfd", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "presenceSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "MotionSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.283Z", + "profile": { + "id": "8ab3ca07-0d07-471b-a276-065e46d7aa8a" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-ecobee3_remote_sensor", + "swVersion": "250206213001", + "hwVersion": "250206213001", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json new file mode 100644 index 00000000000..9becb0923c2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json @@ -0,0 +1,80 @@ +{ + "items": [ + { + "deviceId": "028469cb-6e89-4f14-8d9a-bfbca5e0fbfc", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Main Floor", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.276Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-thermostat", + "swVersion": "250206151734", + "hwVersion": "250206151734", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json new file mode 100644 index 00000000000..6a447ae7aff --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "f1af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "fake-fan", + "label": "Fake fan", + "manufacturerName": "Myself", + "presentationId": "3f0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "profile": { + "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..646196fa980 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "name": "GE Dimmer Switch", + "label": "Basement Exit Light", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", + "deviceManufacturerCode": "0063-4944-3130", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "Basement Exit Light", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + }, + { + "name": "Switch", + "categoryType": "user" + } + ] + } + ], + "createTime": "2020-05-25T18:18:01Z", + "profile": { + "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" + }, + "zwave": { + "networkId": "14", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 99, + "productType": 18756, + "productId": 12592 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json new file mode 100644 index 00000000000..81046859db6 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -0,0 +1,718 @@ +{ + "items": [ + { + "deviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "name": "SmartThings v2 Hub", + "label": "Home Hub", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "bridge", + "version": 1 + } + ], + "categories": [ + { + "name": "Hub", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-13T18:18:07Z", + "childDevices": [ + { + "deviceId": "0781c9d0-92cb-4c7b-bb5b-2f2dbe0c41f3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "08ee0358-9f40-4afa-b5a0-3a6aba18c267", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "09076422-62cc-4b2d-8beb-b53bc451c704", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "0b5577db-5074-4b70-a2c5-efec286d264d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "115236ea-59e5-4cd4-bade-d67c409967bc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1691801c-ae59-438b-89dc-f2c761fe937d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1a987293-0962-4447-99d4-aa82655ffb55", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2533fdd0-e064-4fa2-b77b-1e17260b58d7", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "265e653b-3c0b-4fa6-8e2a-f6a69c7040f0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "277e0a96-c8ec-41aa-b4cf-0bac57dc1cee", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "37c0cdda-9158-41ad-9635-4ca32df9fe5b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "3f82e13c-bd39-4043-bb54-7432a4e47113", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4339f999-1ad2-46fb-9103-cb628b30a022", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4a59f635-9f0a-4a6c-a2f0-ffb7ef182a7c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4c3469c9-3556-4f19-a2e1-1c0a598341dc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4fddedf0-2662-476e-b1fd-aceaec17ad3a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "630cf009-eb3b-409e-a77a-9b298540532f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6356b240-c7d8-403c-883e-ae438d432abe", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6a2e5058-36f3-4668-aa43-49a66f8df93d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6b5535c7-c039-42ee-9970-8af86c6b0775", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6c1b7cfa-7429-4f35-9d02-ab1dfd2f1297", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6ca56087-481f-4e93-9727-fb91049fe396", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6e3e44b3-d84a-4efc-a97b-b5e0dae28ddc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6f4d2e72-7af4-4c96-97ab-d6b6a0d6bc4b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7111243f-39d6-4ed0-a277-f040e40a806d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7b9d924a-de0c-44f9-ac5c-f15869c59411", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7bedac4c-5681-4897-a2ef-e9153cb19ba0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "803cb0d9-addd-4c2d-aaef-d4e20bf88228", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "829da938-6e92-4a93-8923-7c67f9663c03", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "84f1eaf0-592e-459a-a2b3-4fc43e004dae", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8eacf25f-aa33-4d9e-ba90-0e4ac3ceb8e0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8f873071-a9aa-4580-a736-8f5f696e044a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "91172212-e9ff-4ca6-9626-e7af0361c9ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "92138ee5-d3bf-4348-98e8-445dedc319cb", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "971b05df-6ed3-446e-b54f-5092eac01921", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9a9cb299-5279-4dea-9249-b5c153d22ba1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9b479ba0-81e1-4877-87c5-c301a87cbdab", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9dd17f8f-cf5e-4647-a11c-d8f24cdf9b2a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a1e6525c-1e24-403c-b18c-eecb65e22ccf", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9d42ef0-f972-44b0-86bc-efd6569a1aef", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b3a84295-ac3c-4fb1-95e4-4a4bbb1b0bce", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b90c085d-7d1f-4abc-a66d-d5ce3f96be02", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "bafc5147-2e48-498b-97ff-34c93fae7814", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c1107a0c-fa71-43c5-8ff9-a128ea6c4f20", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5209cd2-fcb5-46be-b685-5b05f22dcb2c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5699ff6-af09-4922-901d-bb81b8345bc3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "cfcd9a21-a943-4519-9972-3c7890cd25b1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d20891e5-59b4-46ce-9184-b7fdf0c7ae4c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d48848b9-25b0-4423-8fcf-96a022ac571e", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "ea2aa187-40fd-4140-9742-453e691c4469", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f27d0b27-24fd-4d8c-b003-d3d7aaba1e70", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3c18803-cbec-48e3-8f15-3c31f302d68b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3e184b2-631a-47b2-b583-32ac2fec9e3c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f4e0517a-d94f-4bd6-a464-222c8c413a66", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + } + ], + "profile": { + "id": "d77ba2f6-c377-36f5-bb68-15db9d1aa0e1" + }, + "hub": { + "hubEui": "D052A872947A0001", + "firmwareVersion": "000.055.00005", + "hubDrivers": [ + { + "driverVersion": "2025-01-19T15:05:25.835006968", + "driverId": "00425c55-0932-416f-a1ba-78fae98ab614", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-17T18:00:36.611958104", + "driverId": "01976eca-e7ff-4d1b-91db-9c980ce668d7", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.572636846", + "driverId": "0f206d13-508e-4342-9cbb-937e02489141", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:07.735400483", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-11-04T22:39:17.976631549", + "driverId": "3fb97b6c-f481-441b-a14e-f270d738764e", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:51.437710641", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:35.032104982", + "driverId": "4eb5b19a-7bbc-452f-859b-c6d7d857b2da", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-08-08T18:58:32.479650566", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:47.743217473", + "driverId": "572a2641-2af8-47e4-bfe5-ad83748fd7a1", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-07-12T03:33:26.23424277", + "driverId": "5ad2cc83-5503-4040-a98b-b0fc9931b9fe", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-17T20:08:25.82515546", + "driverId": "5db3363a-d954-412f-93e0-2ee40572658b", + "channelId": "2423da55-101c-4b21-af58-0903656b85ca" + }, + { + "driverVersion": "2024-12-08T10:10:03.832334965", + "driverId": "6342be70-6da0-4535-afc1-ff6378d6c650", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-02-01T21:35:33.624882", + "driverId": "6a90f7a0-e275-4366-bbf2-2e8a502efc5d", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-28T21:56:32.002090649", + "driverId": "7333473f-722c-465d-9e5d-f3a6ca760489", + "channelId": "f8900c5e-d591-4979-9826-75a867e9e0bd" + }, + { + "driverVersion": "2025-02-03T22:38:47.582952919", + "driverId": "7beb8bc2-8dfa-45c2-8fdb-7373d4597b12", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-11-15T16:18:24.739596514", + "driverId": "7ca45ba9-7cfe-4547-b752-fe41a0efb848", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-02-06T21:13:39.427465986", + "driverId": "8bf71a5d-677b-4391-93c2-e76471f3d7eb", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-21T19:06:49.949052991", + "driverId": "9050ac53-358c-47a1-a927-9a70f5f28cee", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:29.754256377", + "driverId": "92f39ab3-7b2f-47ee-94a7-ba47c4ee8a47", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:21.846431345", + "driverId": "9870bccd-2b3d-4edf-8940-532fcb11e946", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-09T21:10:00.424854506", + "driverId": "a6994e70-93de-4a76-8b5d-42971a3427c9", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-01-03T08:19:45.80869", + "driverId": "a89371c4-8765-404b-9de9-e9882cc48bd8", + "channelId": "14bcc056-f80d-416b-9445-467b0db325e3" + }, + { + "driverVersion": "2025-01-11T20:03:43.842469565", + "driverId": "b1504ded-efa4-4ef0-acd5-ae24e7a92e6e", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-08T09:45:01.460678797", + "driverId": "bb1b3fd4-dcba-4d55-8d85-58ed7f1979fb", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-11-04T22:39:18.253781754", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-01-30T21:36:15.547412569", + "driverId": "c856a3fd-69ee-4478-a224-d7279b6d978f", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2025-01-13T18:55:57.509807915", + "driverId": "cd898d81-6c27-4d27-a529-dfadc8caae5a", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.892833142", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:41.208767469", + "driverId": "d620900d-f7bc-4ab5-a171-6dd159872f7d", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-10-10T19:30:33.46670456", + "driverId": "d6b43c85-1561-446b-9e3e-15e2ad81a952", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2023-07-11T18:43:49.169154271", + "driverId": "d9c3f8b8-c3c3-4b77-9ddd-01d08102c84b", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:54.195543653", + "driverId": "dbe192cb-f6a1-4369-a843-d1c42e5c91ba", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2022-10-02T20:15:49.147522379", + "driverId": "e120daf2-8000-4a9d-93fa-653214ce70d1", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2023-08-15T20:08:28.115440571", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2025-02-05T18:49:13.3338494", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + } + ], + "hubData": { + "zwaveStaticDsk": "13740-14339-50623-49310-29679-58685-46457-16097", + "zwaveS2": true, + "hardwareType": "V2_HUB", + "hardwareId": "000D", + "zigbeeFirmware": "5.7.10", + "zigbee3": true, + "zigbeeOta": "ENABLED_WITH_LIGHTS", + "otaEnable": "true", + "zigbeeUnsecureRejoin": true, + "zigbeeRequiresExternalHardware": false, + "threadRequiresExternalHardware": false, + "failoverAvailability": "Unsupported", + "primarySupportAvailability": "Unsupported", + "secondarySupportAvailability": "Unsupported", + "zigbeeAvailability": "Available", + "zwaveAvailability": "Available", + "lanAvailability": "Available", + "matterAvailability": "Available", + "localVirtualDeviceAvailability": "Available", + "childDeviceAvailability": "Unsupported", + "edgeDriversAvailability": "Available", + "hubReplaceAvailability": "Available", + "hubLocalApiAvailability": "Available", + "zigbeeManualFirmwareUpdateSupported": true, + "matterRendezvousHedgeSupported": true, + "matterSoftwareComponentVersion": "1.3-0", + "matterDeviceDiagnosticsAvailability": "Available", + "zigbeeDeviceDiagnosticsAvailability": "Available", + "hedgeTlsCertificate": "", + "zigbeeChannel": "14", + "zigbeePanId": "0EE7", + "zigbeeEui": "D052A872947A0001", + "zigbeeNodeID": "0000", + "zwaveNodeID": "01", + "zwaveHomeID": "CF0F089E", + "zwaveSucID": "01", + "zwaveVersion": "6.10", + "zwaveRegion": "US", + "macAddress": "D0:52:A8:72:91:02", + "localIP": "192.168.168.189", + "zigbeeRadioFunctional": true, + "zwaveRadioFunctional": true, + "zigbeeRadioEnabled": true, + "zwaveRadioEnabled": true, + "zigbeeRadioDetected": true, + "zwaveRadioDetected": true + } + }, + "type": "HUB", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "name": "Multipurpose Sensor", + "label": "Mail Box", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "Mail Box", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "MultiFunctionalSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2022-08-16T21:08:09.983Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010A3A95", + "networkId": "E71B", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..7f729001453 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "440063de-a200-40b5-8a6b-f3399eaa0370", + "name": "hue-color-temperature-bulb", + "label": "Bathroom spot", + "manufacturerName": "0A2r", + "presentationId": "ST_b93bec0e-1a81-4471-83fc-4dddca504acd", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.453Z", + "profile": { + "id": "a79e4507-ecaa-3c7e-b660-a3a71f30eafb" + }, + "viper": { + "uniqueIdentifier": "ea409b82a6184ad9b49bd6318692cc1c", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue ambiance spot", + "swVersion": "1.122.2", + "hwVersion": "LTG002", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..eeca03fec01 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "cb958955-b015-498c-9e62-fc0c51abd054", + "name": "hue-rgbw-color-bulb", + "label": "Standing light", + "manufacturerName": "0A2r", + "presentationId": "ST_2733b8dc-4b0f-4593-8e49-2432202abd52", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorControl", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "samsungim.hueSyncMode", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.454Z", + "profile": { + "id": "71be1b96-c5b5-38f7-a22c-65f5392ce7ed" + }, + "viper": { + "uniqueIdentifier": "f5f891a57b9d45408230b4228bdc2111", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue color lamp", + "swVersion": "1.122.2", + "hwVersion": "LCA001", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json new file mode 100644 index 00000000000..1ae79aa06ef --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -0,0 +1,40 @@ +{ + "items": [ + { + "deviceId": "184c67cc-69e2-44b6-8f73-55c963068ad9", + "name": "iPhone", + "label": "iPhone", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-Mobile_Presence", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "presenceSensor", + "version": 1 + } + ], + "categories": [ + { + "name": "MobilePresence", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-12-02T16:14:24.394Z", + "profile": { + "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" + }, + "type": "MOBILE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json new file mode 100644 index 00000000000..c8088d6473d --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -0,0 +1,77 @@ +{ + "items": [ + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "name": "Multipurpose Sensor", + "label": "Deck Door", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "Deck Door", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "Door", + "categoryType": "user" + } + ] + } + ], + "createTime": "2019-02-23T16:53:57Z", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010AED6B", + "networkId": "C972", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..ae6596755a3 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "bf4b1167-48a3-4af7-9186-0900a678ffa5", + "name": "sensibo-airconditioner-1", + "label": "Office", + "manufacturerName": "0ABU", + "presentationId": "sensibo-airconditioner-1", + "deviceManufacturerCode": "Sensibo", + "locationId": "fe14085e-bacb-4997-bc0c-df08204eaea2", + "ownerId": "49228038-22ca-1c78-d7ab-b774b4569480", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-04T10:10:02.873Z", + "profile": { + "id": "ddaffb28-8ebb-4bd6-9d6f-57c28dcb434d" + }, + "viper": { + "manufacturerName": "Sensibo", + "modelName": "skyplus", + "swVersion": "SKY40147", + "hwVersion": "SKY40147", + "endpointAppId": "viper_5661d200-806e-11e9-abe0-3b2f83c8954c" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json new file mode 100644 index 00000000000..e5ec6c38dad --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -0,0 +1,58 @@ +{ + "items": [ + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "name": "SYLVANIA SMART+ Smart Plug", + "label": "Arlo Beta Basestation", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "28127039-043b-3df0-adf2-7541403dc4c1", + "deviceManufacturerCode": "LEDVANCE", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "Pi Hole", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-10-05T12:23:14Z", + "profile": { + "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" + }, + "zigbee": { + "eui": "F0D1B80000051E05", + "networkId": "801E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json new file mode 100644 index 00000000000..c84caf57475 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "c85fced9-c474-4a47-93c2-037cc7829536", + "name": "sonos-player", + "label": "Elliots Rum", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ef0a871d-9ed1-377d-8746-0da1dfd50598", + "deviceManufacturerCode": "Sonos", + "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", + "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaGroup", + "version": 1 + }, + { + "id": "mediaPresets", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Speaker", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-02T13:18:28.570Z", + "profile": { + "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" + }, + "lan": { + "networkId": "38420B9108F6", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "executingLocally": true, + "hubId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json new file mode 100644 index 00000000000..20f4aa71fec --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -0,0 +1,109 @@ +{ + "items": [ + { + "deviceId": "0d94e5db-8501-2355-eb4f-214163702cac", + "name": "Soundbar", + "label": "Soundbar Living", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-002S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.soundFrom", + "version": 1 + }, + { + "id": "samsungvd.thingStatus", + "version": 1 + }, + { + "id": "samsungvd.audioGroupInfo", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-10-26T02:58:40.549Z", + "profile": { + "id": "3a714028-20ea-3feb-9891-46092132c737" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Soundbar Living", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "HW-Q990C", + "platformVersion": "7.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "SAT-iMX8M23WWC-1010.5", + "vendorId": "VD-NetworkAudio-002S", + "vendorResourceClientServerVersion": "3.2.41", + "lastSignupTime": "2024-10-26T02:58:36.491256384Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json new file mode 100644 index 00000000000..42630f452d5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -0,0 +1,148 @@ +{ + "items": [ + { + "deviceId": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "name": "[TV] Samsung 8 Series (49)", + "label": "[TV] Samsung 8 Series (49)", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-STV_2017_K", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF TV", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "tvChannel", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "custom.error", + "version": 1 + }, + { + "id": "custom.picturemode", + "version": 1 + }, + { + "id": "custom.soundmode", + "version": 1 + }, + { + "id": "custom.accessibility", + "version": 1 + }, + { + "id": "custom.launchapp", + "version": 1 + }, + { + "id": "custom.recording", + "version": 1 + }, + { + "id": "custom.tvsearch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungvd.ambient", + "version": 1 + }, + { + "id": "samsungvd.ambientContent", + "version": 1 + }, + { + "id": "samsungvd.mediaInputSource", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungvd.firmwareVersion", + "version": 1 + }, + { + "id": "samsungvd.supportsPowerOnByOcf", + "version": 1 + } + ], + "categories": [ + { + "name": "Television", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-05-07T02:58:10Z", + "profile": { + "id": "bac5c673-8eea-3d00-b1d2-283b46539017" + }, + "ocf": { + "ocfDeviceType": "oic.d.tv", + "name": "[TV] Samsung 8 Series (49)", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "UN49MU8000", + "platformVersion": "Tizen 3.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "T-KTMAKUC-1290.3", + "vendorId": "VD-STV_2017_K", + "locale": "en_US", + "lastSignupTime": "2021-08-21T18:52:56.748359Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json new file mode 100644 index 00000000000..1b7a55d779d --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "deviceId": "2894dc93-0f11-49cc-8a81-3a684cebebf6", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T22:04:56.174Z", + "profile": { + "id": "e921d7f2-5851-363d-89d5-5e83f5ab44c6" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json new file mode 100644 index 00000000000..e46b7846631 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + "name": "volvo", + "label": "volvo", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "valve", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "WaterValve", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-11T11:27:02.052Z", + "profile": { + "id": "f8e25992-7f5d-31da-b04d-497012590113" + }, + "virtual": { + "name": "volvo", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json new file mode 100644 index 00000000000..ffea2664c88 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "deviceId": "a2a6018b-2663-4727-9d1d-8f56953b5116", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "838ae989-b832-3610-968c-2940491600f6", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "waterSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "LeakSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T21:58:18.688Z", + "profile": { + "id": "39230a95-d42d-34d4-a33c-f79573495a30" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..20f0dd5ca26 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -0,0 +1,66 @@ +{ + "items": [ + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "name": "Yale Push Button Deadbolt Lock", + "label": "Basement Door Lock", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "45f9424f-4e20-34b0-abb6-5f26b189acb0", + "deviceManufacturerCode": "Yale", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "components": [ + { + "id": "main", + "label": "Basement Door Lock", + "capabilities": [ + { + "id": "lock", + "version": 1 + }, + { + "id": "lockCodes", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartLock", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-18T23:01:19Z", + "profile": { + "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" + }, + "zigbee": { + "eui": "000D6F0002FB6E24", + "networkId": "C771", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/locations.json b/tests/components/smartthings/fixtures/locations.json new file mode 100644 index 00000000000..abfa17dc4b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/locations.json @@ -0,0 +1,9 @@ +{ + "items": [ + { + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236c", + "name": "Home" + } + ], + "_links": null +} diff --git a/tests/components/smartthings/fixtures/rooms.json b/tests/components/smartthings/fixtures/rooms.json new file mode 100644 index 00000000000..355db9a3423 --- /dev/null +++ b/tests/components/smartthings/fixtures/rooms.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Theater", + "backgroundImage": null + }, + { + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Toilet", + "backgroundImage": null + } + ], + "_links": null +} diff --git a/tests/components/smartthings/fixtures/scenes.json b/tests/components/smartthings/fixtures/scenes.json new file mode 100644 index 00000000000..aa4f1aaa3d1 --- /dev/null +++ b/tests/components/smartthings/fixtures/scenes.json @@ -0,0 +1,34 @@ +{ + "items": [ + { + "sceneId": "743b0f37-89b8-476c-aedf-eea8ad8cd29d", + "sceneName": "Away", + "sceneIcon": "203", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964737000, + "lastUpdatedDate": 1738964737000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + }, + { + "sceneId": "f3341e8b-9b32-4509-af2e-4f7c952e98ba", + "sceneName": "Home", + "sceneIcon": "204", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964731000, + "lastUpdatedDate": 1738964731000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + } + ], + "_links": { + "next": null, + "previous": null + } +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..27a5e38a123 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -0,0 +1,529 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': '2nd Floor Hallway Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sound', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound', + 'friendly_name': '2nd Floor Hallway Sound', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': '.Front Door Open/Closed Sensor Door', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Child Bedroom Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Child Bedroom Presence', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iphone_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'iPhone Presence', + }), + 'context': , + 'entity_id': 'binary_sensor.iphone_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Acceleration', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'acceleration', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moving', + 'friendly_name': 'Deck Door Acceleration', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door Door', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.volvo_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve', + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'volvo Valve', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.asd_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'asd Moisture', + }), + 'context': , + 'entity_id': 'binary_sensor.asd_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr new file mode 100644 index 00000000000..ba32776011a --- /dev/null +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ac_office_granit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'drlc_status_duration': 0, + 'drlc_status_level': -1, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'AC Office Granit', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': None, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.ac_office_granit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aire_dormitorio_principal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'drlc_status_duration': 0, + 'drlc_status_level': 0, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'high', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Aire Dormitorio Principal', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.aire_dormitorio_principal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.main_floor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 32, + 'current_temperature': 21.7, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Main Floor', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 21.7, + }), + 'context': , + 'entity_id': 'climate.main_floor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + ]), + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.asd', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4734.6, + 'fan_mode': 'followschedule', + 'fan_modes': list([ + 'on', + ]), + 'friendly_name': 'asd', + 'hvac_action': , + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.asd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr new file mode 100644 index 00000000000..aa928c09b7a --- /dev/null +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[c2c_shade][cover.curtain_1a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.curtain_1a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_shade][cover.curtain_1a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'shade', + 'friendly_name': 'Curtain 1A', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.curtain_1a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..50f568df5d1 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -0,0 +1,1163 @@ +# serializer version: 1 +# name: test_device[da_ac_rac_000001] + dict({ + 'events': list([ + ]), + 'status': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.381000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.602000+00:00', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.541000+00:00', + 'unit': '%', + 'value': None, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.498000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.344000+00:00', + 'unit': None, + 'value': None, + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtime': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.843000+00:00', + 'unit': None, + 'value': None, + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.686000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:54.748000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.247000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.325000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'odorSensor': dict({ + 'odorLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.992000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.364000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.291000+00:00', + 'unit': '%', + 'value': 0, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.097000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.518000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.373000+00:00', + 'unit': None, + 'value': None, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:59.136000+00:00', + 'unit': None, + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.529000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + '1', + ]), + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': '2021-12-29T07:29:17.526000+00:00', + 'unit': None, + 'value': 'False', + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2025-01-08T06:30:58.307000+00:00', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', + }), + }), + }), + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270000+00:00', + 'unit': None, + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.782000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.912000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.803000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.933000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'DA-AC-RAC-000001', + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:15:33.639000+00:00', + 'unit': None, + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), + }), + 'refresh': dict({ + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2024-12-30T13:10:23.759000+00:00', + 'unit': '%', + 'value': 60, + }), + }), + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'micomAssayCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelClassificationCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelName': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'releaseYear': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumber': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumberExtra': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': 24070101, + }), + }), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.349000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'result': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'status': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.549000+00:00', + 'unit': None, + 'value': 'ready', + }), + 'supportedActions': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': list([ + 'start', + ]), + }), + }), + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'newVersionAvailable': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'False', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'otnDUID': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'targetModule': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:37:54.072000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:33:29.164000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:15:11.608000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr new file mode 100644 index 00000000000..33caffcacc6 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_all_entities[fake_fan][fan.fake_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fake_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fake_fan][fan.fake_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fake fan', + 'percentage': 2000, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fake_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr new file mode 100644 index 00000000000..fb856ae32d6 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -0,0 +1,1061 @@ +# serializer version: 1 +# name: test_devices[aeotec_home_energy_meter_gen5] + DeviceRegistryEntrySnapshot({ + 'area_id': 'toilet', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f0af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeotec Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Toilet', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[base_electric_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '68e786a6-7f61-4c3a-9e13-70b803cf782b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeon Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_arlo_pro_3_switch] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Arlo', + 'model': 'VMC4041PB', + 'model_id': None, + 'name': '2nd Floor Hallway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_shade] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'WoCurtain3-WoCurtain3', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '571af102-15db-4030-b76b-245a691f74a5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WonderLabs Company', + 'model': 'WoCurtain3', + 'model_id': None, + 'name': 'Curtain 1A', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[centralite] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd0268a69-abfb-4c92-a646-61cec2e510ad', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Dimmer Debian', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[contact_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2d9a892b-1c93-45a5-84cb-0e81889498c6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '.Front Door Open/Closed Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '96a5ef74-5832-a84b-f1f7-ca799957065d', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_KRAC_18K', + 'model_id': None, + 'name': 'AC Office Granit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': '0.1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4ece486b-89db-f06a-d54d-748b676b4d8e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARA-WW-TP1-22-COMMON', + 'model_id': None, + 'name': 'Aire Dormitorio Principal', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ks_microwave_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2bad3237-4886-e699-1b90-4a51a3d55c8a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X', + 'model_id': None, + 'name': 'Microwave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ref_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7db87911-7dce-1cf2-7119-b953432a2f09', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_REF_20K', + 'model_id': None, + 'name': 'Refrigerator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_rvc_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'powerbot_7000_17M', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': '1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_dw_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DW_A51_20_COMMON', + 'model_id': None, + 'name': 'Dishwasher', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'DA_DW_A51_20_COMMON_30230714', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wd_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '02f7256e-8353-5bdd-547f-bd5b1647e01b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Dryer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f984b91d-f250-9d42-3436-33f09a422a47', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_TP2_20_COMMON', + 'model_id': None, + 'name': 'Washer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '250206213001', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd5dc3299-c266-41c7-bd08-f540aea54b89', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ecobee', + 'model': 'aresSmart-ecobee3_remote_sensor', + 'model_id': None, + 'name': 'Child Bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '250206213001', + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '250206151734', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ecobee', + 'model': 'aresSmart-thermostat', + 'model_id': None, + 'name': 'Main Floor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '250206151734', + 'via_device_id': None, + }) +# --- +# name: test_devices[fake_fan] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Fake fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ge_in_wall_smart_dimmer] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Exit Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_color_temperature_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'LTG002', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '440063de-a200-40b5-8a6b-f3399eaa0370', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue ambiance spot', + 'model_id': None, + 'name': 'Bathroom spot', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.122.2', + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_rgbw_color_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'LCA001', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'cb958955-b015-498c-9e62-fc0c51abd054', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue color lamp', + 'model_id': None, + 'name': 'Standing light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.122.2', + 'via_device_id': None, + }) +# --- +# name: test_devices[iphone] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '184c67cc-69e2-44b6-8f73-55c963068ad9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'iPhone', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[multipurpose_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7d246592-93db-4d72-a10d-5a51793ece8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sensibo_airconditioner_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'SKY40147', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Sensibo', + 'model': 'skyplus', + 'model_id': None, + 'name': 'Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'SKY40147', + 'via_device_id': None, + }) +# --- +# name: test_devices[smart_plug] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '550a1c72-65a0-4d55-b97b-75168e055398', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Arlo Beta Basestation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sonos_player] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c85fced9-c474-4a47-93c2-037cc7829536', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Elliots Rum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_network_audio_002s] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '0d94e5db-8501-2355-eb4f-214163702cac', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'HW-Q990C', + 'model_id': None, + 'name': 'Soundbar Living', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'SAT-iMX8M23WWC-1010.5', + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_stv_2017_k] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'UN49MU8000', + 'model_id': None, + 'name': '[TV] Samsung 8 Series (49)', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': 'T-KTMAKUC-1290.3', + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2894dc93-0f11-49cc-8a81-3a684cebebf6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_valve] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'volvo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_water_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a2a6018b-2663-4727-9d1d-8f56953b5116', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[yale_push_button_deadbolt_lock] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a9f587c5-5d8b-4273-8907-e7f609af5158', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Door Lock', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_hub_via_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'd0:52:a8:72:91:02', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'V2_HUB', + 'model_id': None, + 'name': 'Home Hub', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': '000.055.00005', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr new file mode 100644 index 00000000000..8766811c443 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -0,0 +1,267 @@ +# serializer version: 1 +# name: test_all_entities[centralite][light.dimmer_debian-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_debian', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][light.dimmer_debian-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer Debian', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_debian', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.basement_exit_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Basement Exit Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.basement_exit_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bathroom_spot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 178, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 3000, + 'friendly_name': 'Bathroom spot', + 'hs_color': tuple( + 27.825, + 56.895, + ), + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bathroom_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.standing_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Standing light', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.standing_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr new file mode 100644 index 00000000000..2cf9688c3dd --- /dev/null +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.basement_door_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Basement Door Lock', + 'lock_state': 'locked', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.basement_door_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_scene.ambr b/tests/components/smartthings/snapshots/test_scene.ambr new file mode 100644 index 00000000000..fd9abc9fcca --- /dev/null +++ b/tests/components/smartthings/snapshots/test_scene.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_all_entities[scene.away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.away', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '743b0f37-89b8-476c-aedf-eea8ad8cd29d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Away', + 'icon': '203', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[scene.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f3341e8b-9b32-4509-af2e-4f7c952e98ba', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Home', + 'icon': '204', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..78aa4db62f8 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -0,0 +1,4881 @@ +# serializer version: 1 +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeotec Energy Monitor Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19978.536', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeotec Energy Monitor Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2859.743', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aeotec Energy Monitor Voltage', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeon_energy_monitor_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeon Energy Monitor Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1930.362', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeon_energy_monitor_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeon Energy Monitor Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '938.3', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': '2nd Floor Hallway Alarm', + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '2nd Floor Hallway Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dimmer_debian_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dimmer Debian Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.dimmer_debian_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '.Front Door Open/Closed Sensor Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2247.3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AC Office Granit Power', + 'power_consumption_end': '2025-02-09T16:15:33Z', + 'power_consumption_start': '2025-02-09T15:45:29Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AC Office Granit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.836', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aire Dormitorio Principal Power', + 'power_consumption_end': '2025-02-09T17:02:44Z', + 'power_consumption_start': '2025-02-09T16:08:15Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aire Dormitorio Principal Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Completion time', + }), + 'context': , + 'entity_id': 'sensor.microwave_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T21:13:36.184Z', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Microwave Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.microwave_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Microwave Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.microwave_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.microwave_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Microwave Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'others', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_set_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Set point', + }), + 'context': , + 'entity_id': 'sensor.microwave_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Microwave Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.microwave_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1568.087', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Refrigerator Power', + 'power_consumption_end': '2025-02-09T17:49:00Z', + 'power_consumption_start': '2025-02-09T17:38:01Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0135559777781698', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_cleaning_mode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_movement', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turbo mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_turbo_mode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dishwasher Completion time', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T22:49:26+00:00', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.6', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_job_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dishwasher Job state', + 'options': list([ + 'air_wash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), + }), + 'context': , + 'entity_id': 'sensor.dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_machine_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dishwasher Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dishwasher Power', + 'power_consumption_end': '2025-02-08T20:21:26Z', + 'power_consumption_start': '2025-02-08T20:21:21Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer Completion time', + }), + 'context': , + 'entity_id': 'sensor.dryer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T19:25:10+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4495.5', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dryer Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Dryer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer Power', + 'power_consumption_end': '2025-02-08T18:10:11Z', + 'power_consumption_start': '2025-02-07T04:00:19Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer Completion time', + }), + 'context': , + 'entity_id': 'sensor.washer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-07T03:54:45+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '352.8', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + 'power_consumption_end': '2025-02-07T03:09:45Z', + 'power_consumption_start': '2025-02-07T03:09:24Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.child_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Child Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.child_bedroom_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_floor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Main Floor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.main_floor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_floor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Main Floor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.main_floor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.deck_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Deck Door Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.deck_door_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.4', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_x_coordinate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'X coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'x_coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door X coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_x_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_y_coordinate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Y coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'y_coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Y coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_y_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_z_coordinate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Z coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'z_coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Z coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_z_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1042', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air conditioner mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_conditioner_mode', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Air conditioner mode', + }), + 'context': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_cooling_set_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_cooling_setpoint', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Cooling set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_cooling_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Elliots Rum Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elliots_rum_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Soundbar Living Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_living_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media input source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_input_source', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'hdmi1', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TV channel', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tv_channel', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TV channel name', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tv_channel_name', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel name', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.asd_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'asd Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.asd_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4734.552604985020', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Door Lock Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '86', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr new file mode 100644 index 00000000000..d12bd4ea5b6 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -0,0 +1,471 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.2nd_floor_hallway', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway', + }), + 'context': , + 'entity_id': 'switch.2nd_floor_hallway', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.microwave', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave', + }), + 'context': , + 'entity_id': 'switch.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.robot_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dishwasher', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + }), + 'context': , + 'entity_id': 'switch.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dryer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + }), + 'context': , + 'entity_id': 'switch.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + }), + 'context': , + 'entity_id': 'switch.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.office', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office', + }), + 'context': , + 'entity_id': 'switch.office', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.arlo_beta_basestation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arlo Beta Basestation', + }), + 'context': , + 'entity_id': 'switch.arlo_beta_basestation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.soundbar_living', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living', + }), + 'context': , + 'entity_id': 'switch.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.tv_samsung_8_series_49', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49)', + }), + 'context': , + 'entity_id': 'switch.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 52fd5d28aa7..f46be2edc89 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -1,139 +1,53 @@ -"""Test for the SmartThings binary_sensor platform. +"""Test for the SmartThings binary_sensor platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES, - DOMAIN as BINARY_SENSOR_DOMAIN, -) -from homeassistant.components.smartthings import binary_sensor -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - # Ensure every CAPABILITY_TO_ATTRIB key is in CAPABILITIES - # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys - for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items(): - assert capability in CAPABILITIES, capability - assert attrib in ATTRIBUTES, attrib - assert attrib in binary_sensor.ATTRIB_TO_CLASS, attrib - # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES - for attrib, device_class in binary_sensor.ATTRIB_TO_CLASS.items(): - assert attrib in ATTRIBUTES, attrib - assert device_class in DEVICE_CLASSES, device_class - - -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the light types.""" - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state.state == "off" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} {Attribute.motion}" - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Motion Sensor 1", - [Capability.motion_sensor], - { - Attribute.motion: "inactive", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.motion}" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.motion_sensor, Attribute.motion, "active" - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - config_entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "binary_sensor") - # Assert - assert ( - hass.states.get("binary_sensor.motion_sensor_1_motion").state - == STATE_UNAVAILABLE + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.BINARY_SENSOR ) -async def test_entity_category( - hass: HomeAssistant, entity_registry: er.EntityRegistry, device_factory +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the light types.""" - device1 = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - device2 = device_factory( - "Tamper Sensor 2", [Capability.tamper_alert], {Attribute.tamper: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device1, device2]) + """Test state update.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.entity_category is None + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF - entry = entity_registry.async_get("binary_sensor.tamper_sensor_2_tamper") - assert entry - assert entry.entity_category is EntityCategory.DIAGNOSTIC + await trigger_update( + hass, + devices, + "7db87911-7dce-1cf2-7119-b953432a2f09", + Capability.CONTACT_SENSOR, + Attribute.CONTACT, + "open", + ) + + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index d39ee2d6bed..380c4072860 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -1,12 +1,11 @@ -"""Test for the SmartThings climate platform. +"""Test for the SmartThings climate platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command, Status import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -26,748 +25,835 @@ from homeassistant.components.climate import ( SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - ClimateEntityFeature, + SWING_HORIZONTAL, + SWING_OFF, HVACAction, HVACMode, ) -from homeassistant.components.smartthings import climate -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="legacy_thermostat") -def legacy_thermostat_fixture(device_factory): - """Fixture returns a legacy thermostat.""" - device = device_factory( - "Legacy Thermostat", - capabilities=[Capability.thermostat], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "auto", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "auto", - Attribute.supported_thermostat_modes: climate.MODE_TO_STATE.keys(), - Attribute.thermostat_operating_state: "idle", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="basic_thermostat") -def basic_thermostat_fixture(device_factory): - """Fixture returns a basic thermostat.""" - device = device_factory( - "Basic Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "auto", "heat", "cool"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="minimal_thermostat") -def minimal_thermostat_fixture(device_factory): - """Fixture returns a minimal thermostat without cooling.""" - device = device_factory( - "Minimal Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "heat"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="thermostat") -def thermostat_fixture(device_factory): - """Fixture returns a fully-featured thermostat.""" - device = device_factory( - "Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.relative_humidity_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - Capability.thermostat_fan_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "on", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "heat", - Attribute.supported_thermostat_modes: [ - "auto", - "heat", - "cool", - "off", - "eco", - ], - Attribute.thermostat_operating_state: "idle", - Attribute.humidity: 34, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="buggy_thermostat") -def buggy_thermostat_fixture(device_factory): - """Fixture returns a buggy thermostat.""" - device = device_factory( - "Buggy Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.thermostat_mode: "heating", - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="air_conditioner") -def air_conditioner_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "fanOnly", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -@pytest.fixture(name="air_conditioner_windfree") -def air_conditioner_windfree_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "wind", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -async def test_legacy_thermostat_entity_state( - hass: HomeAssistant, legacy_thermostat +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[legacy_thermostat]) - state = hass.states.get("climate.legacy_thermostat") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "auto" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20 # celsius - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 23.3 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.CLIMATE) -async def test_basic_thermostat_entity_state( - hass: HomeAssistant, basic_thermostat +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[basic_thermostat]) - state = hass.states.get("climate.basic_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test climate set fan mode.""" + await setup_integration(hass, mock_config_entry) - -async def test_minimal_thermostat_entity_state( - hass: HomeAssistant, minimal_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[minimal_thermostat]) - state = hass.states.get("climate.minimal_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.HEAT, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - - -async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "on" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TEMPERATURE] == 20 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 34 - - -async def test_buggy_thermostat_entity_state( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.state == STATE_UNKNOWN - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.state is STATE_UNKNOWN - assert state.attributes[ATTR_TEMPERATURE] is None - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_HVAC_MODES] == [] - - -async def test_buggy_thermostat_invalid_mode( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests when an invalid operation mode is included.""" - buggy_thermostat.status.update_attribute_value( - Attribute.supported_thermostat_modes, ["heat", "emergency heat", "other"] - ) - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - - -async def test_air_conditioner_entity_state( - hass: HomeAssistant, air_conditioner -) -> None: - """Tests when an invalid operation mode is included.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.DRY, - HVACMode.FAN_ONLY, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "medium" - assert sorted(state.attributes[ATTR_FAN_MODES]) == [ - "auto", - "high", - "low", - "medium", - "turbo", - ] - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24 - assert state.attributes["drlc_status_duration"] == 0 - assert state.attributes["drlc_status_level"] == -1 - assert state.attributes["drlc_status_start"] == "1970-01-01T00:00:00Z" - assert state.attributes["drlc_status_override"] is False - - -async def test_set_fan_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the fan mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_FAN_MODE: "auto"}, blocking=True, ) - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.attributes[ATTR_FAN_MODE] == "auto", entity_id + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="auto", + ) -async def test_set_hvac_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the hvac mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode to off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_HVAC_MODE: HVACMode.COOL}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.state == HVACMode.COOL, entity_id - - -async def test_ac_set_hvac_mode_from_off(hass: HomeAssistant, air_conditioner) -> None: - """Test setting HVAC mode when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("hvac_mode", "argument"), + [ + (HVACMode.HEAT_COOL, "auto"), + (HVACMode.COOL, "cool"), + (HVACMode.DRY, "dry"), + (HVACMode.HEAT, "heat"), + (HVACMode.FAN_ONLY, "fanOnly"), + ], +) +async def test_ac_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + argument: str, +) -> None: + """Test setting AC HVAC mode.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_turns_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode turns on the device if it is off.""" + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.air_conditioner", + ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_ac_set_hvac_mode_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the AC HVAC mode can be turned off set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_set_hvac_mode_wind( - hass: HomeAssistant, air_conditioner_windfree + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the AC HVAC mode to fan only as wind mode for supported models.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner_windfree]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF + """Test setting AC HVAC mode to wind if the device supports it.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "wind"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.FAN_ONLY + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="wind", + ) -async def test_set_temperature_heat_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in heat mode.""" - thermostat.status.thermostat_mode = "heat" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_TEMPERATURE: 23}, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 21 - assert thermostat.status.heating_setpoint == 69.8 - - -async def test_set_temperature_cool_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in cool mode.""" - thermostat.status.thermostat_mode = "cool" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TEMPERATURE] == 21 -async def test_set_temperature(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully.""" - thermostat.status.thermostat_mode = "auto" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_while_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature and HVAC mode while off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac(hass: HomeAssistant, air_conditioner) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_TEMPERATURE: 27}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - - -async def test_set_temperature_ac_with_mode( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + """Test setting AC temperature and HVAC mode.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac_with_mode_from_off( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temp and mode is set successfully when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" - ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state == HVACMode.OFF + """Test setting AC temperature and HVAC mode OFF.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL - - -async def test_set_temperature_ac_with_mode_to_off( - hass: HomeAssistant, air_conditioner -) -> None: - """Test the temp and mode is set successfully to turn off the unit.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + ] -async def test_set_temperature_with_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature and mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 - assert state.state == HVACMode.HEAT_COOL - - -async def test_set_turn_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned off successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_OFF, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - - -async def test_set_turn_on(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned on successfully.""" - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_entity_and_device_attributes( +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_ac_toggle_power( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - thermostat, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, ) -> None: - """Test the attributes of the entries are correct.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + """Test toggling AC power.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("climate.thermostat") - assert entry - assert entry.unique_id == thermostat.device_id - - entry = device_registry.async_get_device( - identifiers={(DOMAIN, thermostat.device_id)} - ) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, thermostat.device_id)} - assert entry.name == thermostat.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_set_windfree_off(hass: HomeAssistant, air_conditioner) -> None: - """Test if the windfree preset can be turned on and is turned off when fan mode is set.""" - entity_ids = ["climate.air_conditioner"] - air_conditioner.status.update_attribute_value(Attribute.switch, "on") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_PRESET_MODE: "windFree"}, + service, + {ATTR_ENTITY_ID: "climate.ac_office_granit"}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_PRESET_MODE] == "windFree" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "low"}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + command, + MAIN, ) - state = hass.states.get("climate.air_conditioner") - assert not state.attributes[ATTR_PRESET_MODE] -async def test_set_swing_mode(hass: HomeAssistant, air_conditioner) -> None: - """Test the fan swing is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - entity_ids = ["climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_swing_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set swing mode.""" + set_attribute_value( + devices, + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ["fixed"], + ) + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_SWING_MODE: "vertical"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_SWING_MODE: SWING_OFF}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_SWING_MODE] == "vertical" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + MAIN, + argument="fixed", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set preset mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: "windFree"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + MAIN, + argument="windFree", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Attribute.SWITCH, + "on", + ) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.HEAT + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 25, + 20, + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.FAN_MODE, + "auto", + ATTR_FAN_MODE, + "low", + "auto", + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.SUPPORTED_AC_FAN_MODES, + ["low", "auto"], + ATTR_FAN_MODES, + ["auto", "low", "medium", "high", "turbo"], + ["low", "auto"], + ), + ( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 23, + ATTR_TEMPERATURE, + 25, + 23, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "horizontal", + ATTR_SWING_MODE, + SWING_OFF, + SWING_HORIZONTAL, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "direct", + ATTR_SWING_MODE, + SWING_OFF, + SWING_OFF, + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_TEMPERATURE, + ATTR_SWING_MODE, + f"{ATTR_SWING_MODE}_off", + ], +) +async def test_ac_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == original_value + ) + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + capability, + attribute, + value, + ) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == expected_value + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set fan mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_FAN_MODE: "on"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + MAIN, + argument="on", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="auto", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ("state", "data", "calls"), + [ + ( + "auto", + {ATTR_TARGET_TEMP_LOW: 15, ATTR_TARGET_TEMP_HIGH: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=59.0, + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ( + "cool", + {ATTR_TEMPERATURE: 15}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=59.0, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=73.4, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.COOL}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="cool", + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ], +) +async def test_thermostat_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + state: str, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test thermostat set temperature.""" + set_attribute_value( + devices, Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE, state + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.asd"} | data, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == calls + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_updating_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.HUMIDITY, + 40, + ) + + assert hass.states.get("climate.asd").attributes[ATTR_CURRENT_HUMIDITY] == 40 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 4734.6, + -6.7, + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.THERMOSTAT_FAN_MODE, + "auto", + ATTR_FAN_MODE, + "followschedule", + "auto", + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.SUPPORTED_THERMOSTAT_FAN_MODES, + ["auto", "circulate"], + ATTR_FAN_MODES, + ["on"], + ["auto", "circulate"], + ), + ( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + "fan only", + ATTR_HVAC_ACTION, + HVACAction.COOLING, + HVACAction.FAN, + ), + ( + Capability.THERMOSTAT_MODE, + Attribute.SUPPORTED_THERMOSTAT_MODES, + ["coolClean", "dryClean"], + ATTR_HVAC_MODES, + [], + [HVACMode.COOL, HVACMode.DRY], + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ], +) +async def test_thermostat_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.asd").attributes[state_attribute] == original_value + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + capability, + attribute, + value, + ) + + assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 3621e58bc3d..a16747c1190 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -1,759 +1,541 @@ """Tests for the SmartThings config flow module.""" from http import HTTPStatus -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError -from pysmartthings.installedapp import format_install_url +import pytest -from homeassistant import config_entries -from homeassistant.components.smartthings import smartapp +from homeassistant.components.smartthings import OLD_DATA from homeassistant.components.smartthings.const import ( - CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -async def test_import_shows_user_step(hass: HomeAssistant) -> None: - """Test import source shows the user form.""" - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - -async def test_entry_created( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: - """Test local webhook, new app, install event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown + """Check a full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id + DOMAIN, context={"source": SOURCE_USER} ) - -async def test_entry_created_from_update_event( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test local webhook, new app, update event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_update(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_new_oauth_client( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and generation of a new oauth client.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.generate_app_oauth.return_value = app_oauth_client - smartthings_mock.locations.return_value = [location] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_copies_oauth_client( - hass: HomeAssistant, app, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and copies the oauth client from another entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - oauth_client_id = str(uuid4()) - oauth_client_secret = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: oauth_client_id, - CONF_CLIENT_SECRET: oauth_client_secret, - CONF_LOCATION_ID: str(uuid4()), - CONF_INSTALLED_APP_ID: str(uuid4()), - CONF_ACCESS_TOKEN: token, + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, ) - entry.add_to_hass(hass) - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - # Assert access token is defaulted to an existing entry for convenience. - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" + ) - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) - # Finish result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == oauth_client_secret - assert result["data"][CONF_CLIENT_ID] == oauth_client_id - assert result["title"] == location.name - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_INSTALLED_APP_ID] == installed_app_id - ), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) + result["data"]["token"].pop("expires_at") + assert result["data"][CONF_TOKEN] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } + assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" -async def test_entry_created_with_cloudhook( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +@pytest.mark.usefixtures("current_request_with_host") +async def test_not_enough_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: - """Test cloud, new app, install event creates entry.""" - hass.config.components.add("cloud") - # Unload the endpoint so we can reload it under the cloud. - await smartapp.unload_smartapp_endpoint(hass) - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - smartthings_mock.locations = AsyncMock(return_value=[location]) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token + """Test we abort if we don't have enough scopes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - with ( - patch.object( - smartapp.cloud, - "async_active_subscription", - Mock(return_value=True), - ), - patch.object( - smartapp.cloud, - "async_create_cloudhook", - AsyncMock(return_value="http://cloud.test"), - ) as mock_create_cloudhook, - ): - await smartapp.setup_smartapp_endpoint(hass, True) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - # One is done by app fixture, one done by new config entry - assert mock_create_cloudhook.call_count == 2 - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_invalid_webhook_aborts(hass: HomeAssistant) -> None: - """Test flow aborts if webhook is invalid.""" - # Webhook confirmation shown - await async_process_ha_core_config( + state = config_entry_oauth2_flow._encode_jwt( hass, - {"external_url": "http://example.local:8123"}, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "invalid_webhook_url" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - assert "component_url" in result["description_placeholders"] + assert result["reason"] == "missing_scopes" -async def test_invalid_token_shows_error(hass: HomeAssistant) -> None: - """Test an error is shown for invalid token formats.""" - token = "123456789" - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_invalid_format"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unauthorized_token_shows_error( - hass: HomeAssistant, smartthings_mock +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test an error is shown for unauthorized token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Webhook confirmation shown + """Test duplicate entry is not able to set up.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_forbidden_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for forbidden token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN + DOMAIN, context={"source": SOURCE_USER} ) - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" -async def test_webhook_problem_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there's an problem with the webhook endpoint.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.UNPROCESSABLE_ENTITY, + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, ) - error.is_target_error = Mock(return_value=True) - smartthings_mock.apps.side_effect = error - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "webhook_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_api_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when other API errors occur.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.BAD_REQUEST, - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_response_error_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - error = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.NOT_FOUND - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - smartthings_mock.apps.side_effect = Exception("Unknown error") - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_no_available_locations_aborts( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test select location aborts if no available locations.""" - token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_available_locations" + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + mock_config_entry.data["token"].pop("expires_at") + assert mock_config_entry.data[CONF_TOKEN] == { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_wrong_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong scopes.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_config_entry.add_to_hass(hass) + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_old_config_entry.state is ConfigEntryState.LOADED + assert len(hass.config_entries.flow.async_progress()) == 0 + mock_old_config_entry.data[CONF_TOKEN].pop("expires_at") + assert mock_old_config_entry.data == { + "auth_implementation": DOMAIN, + "old_data": { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + CONF_TOKEN: { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + } + assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_wrong_location( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong location.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_location_mismatch" + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_old_config_entry.data == { + OLD_DATA: { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + } + } + assert ( + mock_old_config_entry.unique_id + == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" + ) + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 31443c12ab2..37f12b44880 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -1,249 +1,192 @@ -"""Test for the SmartThings cover platform. +"""Test for the SmartThings cover platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, Status +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as COVER_DOMAIN, +) +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, - CoverState, + STATE_OPEN, + STATE_OPENING, + Platform, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Garage", - [Capability.garage_door_control], - { - Attribute.door: "open", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.COVER) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_COVER, Command.OPEN), + (SERVICE_CLOSE_COVER, Command.CLOSE), + ], +) +async def test_cover_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test cover open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + action, + {ATTR_ENTITY_ID: "cover.curtain_1a"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + command, + MAIN, ) - # Act - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("cover.garage") - assert entry - assert entry.unique_id == device.device_id - - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" -async def test_open(hass: HomeAssistant, device_factory) -> None: - """Test the cover opens doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "closed"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closed"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "closed"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_set_position( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cover set position command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.curtain_1a", ATTR_POSITION: 25}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=25, + ) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True - ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.OPENING + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 -async def test_close(hass: HomeAssistant, device_factory) -> None: - """Test the cover closes doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "open"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "open"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery_updating( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.BATTERY, + Attribute.BATTERY, + 49, ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.CLOSING + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 49 -async def test_set_cover_position_switch_level( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the cover sets to the specific position for legacy devices that use Capability.switch_level.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.switch_level], - {Attribute.window_shade: "opening", Attribute.battery: 95, Attribute.level: 10}, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + Attribute.WINDOW_SHADE, + "opening", ) - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 + assert hass.states.get("cover.curtain_1a").state == STATE_OPENING -async def test_set_cover_position(hass: HomeAssistant, device_factory) -> None: - """Test the cover sets to the specific position.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.window_shade_level], - { - Attribute.window_shade: "opening", - Attribute.battery: 95, - Attribute.shade_level: 10, - }, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, - ) - - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 - - -async def test_set_cover_position_unsupported( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_position_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test set position does nothing when not supported by device.""" - # Arrange - device = device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {"entity_id": "all", ATTR_POSITION: 50}, - blocking=True, + """Test position update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 100 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 50, ) - state = hass.states.get("cover.shade") - assert ATTR_CURRENT_POSITION not in state.attributes - - # Ensure API was not called - - assert device._api.post_device_command.call_count == 0 - - -async def test_update_to_open_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the cover updates to open when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "open") - assert hass.states.get("cover.garage").state == CoverState.OPENING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.OPEN - - -async def test_update_to_closed_from_signal( - hass: HomeAssistant, device_factory -) -> None: - """Test the cover updates to closed when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closing"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "closed") - assert hass.states.get("cover.garage").state == CoverState.CLOSING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.CLOSED - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ) - config_entry = await setup_platform(hass, COVER_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, COVER_DOMAIN) - # Assert - assert hass.states.get("cover.garage").state == STATE_UNAVAILABLE + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py new file mode 100644 index 00000000000..768be155c86 --- /dev/null +++ b/tests/components/smartthings/test_diagnostics.py @@ -0,0 +1,49 @@ +"""Test SmartThings diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_device +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} + ) + + mock_smartthings.get_device_status.reset_mock() + + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): + diag = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert diag == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) + mock_smartthings.get_device_status.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d" + ) diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index b78c453b402..58287355381 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -1,433 +1,168 @@ -"""Test for the SmartThings fan platform. +"""Test for the SmartThings fan platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, - FanEntityFeature, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the fan types.""" - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Dimmer 1 - state = hass.states.get("fan.fan_1") - assert state.state == "on" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "on", - Attribute.fan_speed: 2, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("fan.fan_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.FAN) -# Setup platform tests with varying capabilities -async def test_setup_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the mode capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -async def test_setup_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_setup_both_capabilities(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with both the mode and speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[ - Capability.switch, - Capability.fan_speed, - Capability.air_conditioner_fan_mode, - ], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -# Speed Capability Tests - - -async def test_turn_off_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_speed_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, ) -> None: - """Test the fan turns on to the specified speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + """Test turning on and off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "turn_on", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: "fan.fake_fan"}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 - - -async def test_turn_off_with_speed_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan turns off with the speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 100}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + command, + MAIN, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 0}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_set_percentage_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + Command.OFF, + MAIN, + ) -async def test_update_from_signal_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the fan is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - config_entry = await setup_platform(hass, FAN_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "fan") - # Assert - assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE - - -# Preset Mode Tests - - -async def test_turn_off_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "on", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_turn_on_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_update_from_signal_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_set_preset_mode_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan mode.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", - "set_preset_mode", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PRESET_MODE: "low"}, + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.attributes[ATTR_PRESET_MODE] == "low" + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, + ) + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PRESET_MODE: "turbo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="turbo", + ) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e518f84aecb..3ffe2c11a42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,574 +1,84 @@ """Tests for the SmartThings component init module.""" -from collections.abc import Callable, Coroutine -from datetime import datetime, timedelta -from http import HTTPStatus -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientConnectionError, ClientResponseError -from pysmartthings import InstalledAppStatus, OAuthToken +from pysmartthings import DeviceResponse, DeviceStatus import pytest +from syrupy import SnapshotAssertion -from homeassistant import config_entries -from homeassistant.components import cloud, smartthings -from homeassistant.components.smartthings.const import ( - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, -) +from homeassistant.components.smartthings.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from . import setup_integration + +from tests.common import MockConfigEntry, load_fixture -async def test_migration_creates_new_flow( - hass: HomeAssistant, smartthings_mock, config_entry +async def test_devices( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test migration deletes app and creates new flow.""" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry(config_entry, version=1) + device_id = devices.get_devices.return_value[0].device_id - await smartthings.async_migrate_entry(hass, config_entry) + device = device_registry.async_get_device({(DOMAIN, device_id)}) + + assert device is not None + assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_removing_stale_devices( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing stale devices.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "aaa-bbb-ccc")}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} + assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) -async def test_unrecoverable_api_errors_create_new_flow( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test a new config flow is initiated when there are API errors. - - 401 (unauthorized): Occurs when the access token is no longer valid. - 403 (forbidden/not found): Occurs when the app or installed app could - not be retrieved/found (likely deleted?) - """ - - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) - assert not result - - # Assert entry was removed and new flow created - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - hass.config_entries.flow.async_abort(flows[0]["flow_id"]) - - -async def test_recoverable_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for recoverable API errors.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_connection_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for connection errors.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.side_effect = ClientConnectionError() - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_base_url_no_longer_https_does_not_load( - hass: HomeAssistant, config_entry, app, smartthings_mock -) -> None: - """Test base_url no longer valid creates a new flow.""" - await async_process_ha_core_config( - hass, - {"external_url": "http://example.local:8123"}, - ) - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - - # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) - assert not result - - -async def test_unauthorized_installed_app_raises_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test config entry not ready raised when the app isn't authorized.""" - config_entry.add_to_hass(hass) - installed_app.installed_app_status = InstalledAppStatus.PENDING - - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_unauthorized_loads_platforms( +async def test_hub_via_device( hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_smartthings: AsyncMock, ) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities + """Test hub with child devices.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture("devices/hub.json", DOMAIN) + ).items + mock_smartthings.get_device_status.side_effect = [ + DeviceStatus.from_json( + load_fixture(f"device_status/{fixture}.json", DOMAIN) + ).components + for fixture in ("hub", "multipurpose_sensor") ] - smartthings_mock.subscriptions.return_value = subscriptions + await setup_integration(hass, mock_config_entry) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_config_entry_loads_platforms( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test config entry loads properly and proxies to platforms.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_config_entry_loads_unconnected_cloud( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test entry loads during startup when cloud isn't connected.""" - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_unload_entry(hass: HomeAssistant, config_entry) -> None: - """Test entries are unloaded correctly.""" - connect_disconnect = Mock() - smart_app = Mock() - smart_app.connect_event.return_value = connect_disconnect - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), smart_app, [], []) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker - - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as forward_mock: - assert await smartthings.async_unload_entry(hass, config_entry) - - assert connect_disconnect.call_count == 1 - assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS] - # Assert platforms unloaded - await hass.async_block_till_done() - assert forward_mock.call_count == len(PLATFORMS) - - -async def test_remove_entry( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app and app are removed up.""" - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_cloudhook( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app, app, and cloudhook are removed up.""" - hass.config.components.add("cloud") - # Arrange - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - # Act - with ( - patch.object( - cloud, "async_is_logged_in", return_value=True - ) as mock_async_is_logged_in, - patch.object(cloud, "async_delete_cloudhook") as mock_async_delete_cloudhook, - ): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert mock_async_is_logged_in.call_count == 1 - assert mock_async_delete_cloudhook.call_count == 1 - - -async def test_remove_entry_app_in_use( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test app is not removed if in use by another config entry.""" - # Arrange - config_entry.add_to_hass(hass) - data = config_entry.data.copy() - data[CONF_INSTALLED_APP_ID] = str(uuid4()) - entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data) - entry2.add_to_hass(hass) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_already_deleted( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test handles when the apps have already been removed.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN + hub_device = device_registry.async_get_device( + {(DOMAIN, "074fa784-8be8-4c70-8e22-6f5ed6f81b7e")} ) - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN + assert hub_device == snapshot + assert ( + device_registry.async_get_device( + {(DOMAIN, "374ba6fa-5a08-4ea2-969c-1fa43d86e21f")} + ).via_device_id + == hub_device.id ) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_installedapp_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_installedapp_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - # Arrange - smartthings_mock.delete_installed_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_app_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - request_info = Mock(real_url="http://example.com") - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_app_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - smartthings_mock.delete_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_broker_regenerates_token(hass: HomeAssistant, config_entry) -> None: - """Test the device broker regenerates the refresh token.""" - token = Mock(OAuthToken) - token.refresh_token = str(uuid4()) - stored_action = None - config_entry.add_to_hass(hass) - - def async_track_time_interval( - hass: HomeAssistant, - action: Callable[[datetime], Coroutine[Any, Any, None] | None], - interval: timedelta, - ) -> None: - nonlocal stored_action - stored_action = action - - with patch( - "homeassistant.components.smartthings.async_track_time_interval", - new=async_track_time_interval, - ): - broker = smartthings.DeviceBroker(hass, config_entry, token, Mock(), [], []) - broker.connect() - - assert stored_action - await stored_action(None) - assert token.refresh.call_count == 1 - assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token - - -async def test_event_handler_dispatches_updated_devices( - hass: HomeAssistant, - config_entry, - device_factory, - event_request_factory, - event_factory, -) -> None: - """Test the event handler dispatches updated devices.""" - devices = [ - device_factory("Bedroom 1 Switch", ["switch"]), - device_factory("Bathroom 1", ["switch"]), - device_factory("Sensor", ["motionSensor"]), - device_factory("Lock", ["lock"]), - ] - device_ids = [ - devices[0].device_id, - devices[1].device_id, - devices[2].device_id, - devices[3].device_id, - ] - event = event_factory( - devices[3].device_id, - capability="lock", - attribute="lock", - value="locked", - data={"codeId": "1"}, - ) - request = event_request_factory(device_ids=device_ids, events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def signal(ids): - nonlocal called - called = True - assert device_ids == ids - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), devices, []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - for device in devices: - assert device.status.values["Updated"] == "Value" - assert devices[3].status.attributes["lock"].value == "locked" - assert devices[3].status.attributes["lock"].data == {"codeId": "1"} - - broker.disconnect() - - -async def test_event_handler_ignores_other_installed_app( - hass: HomeAssistant, config_entry, device_factory, event_request_factory -) -> None: - """Test the event handler dispatches updated devices.""" - device = device_factory("Bedroom 1 Switch", ["switch"]) - request = event_request_factory([device.device_id]) - called = False - - def signal(ids): - nonlocal called - called = True - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert not called - - broker.disconnect() - - -async def test_event_handler_fires_button_events( - hass: HomeAssistant, - config_entry, - device_factory, - event_factory, - event_request_factory, -) -> None: - """Test the event handler fires button events.""" - device = device_factory("Button 1", ["button"]) - event = event_factory( - device.device_id, capability="button", attribute="button", value="pushed" - ) - request = event_request_factory(events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def handler(evt): - nonlocal called - called = True - assert evt.data == { - "component_id": "main", - "device_id": device.device_id, - "location_id": event.location_id, - "value": "pushed", - "name": device.label, - "data": None, - } - - hass.bus.async_listen(EVENT_BUTTON, handler) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - - broker.disconnect() diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index b46188b5b5f..56eadde748b 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -1,342 +1,415 @@ -"""Test for the SmartThings light platform. +"""Test for the SmartThings light platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command import pytest +from syrupy import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ColorMode, - LightEntityFeature, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data -@pytest.fixture(name="light_devices") -def light_devices_fixture(device_factory): - """Fixture returns a set of mock light devices.""" - return [ - device_factory( - "Dimmer 1", - capabilities=[Capability.switch, Capability.switch_level], - status={Attribute.switch: "on", Attribute.level: 100}, - ), - device_factory( - "Color Dimmer 1", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - ], - status={ - Attribute.switch: "off", - Attribute.level: 0, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - }, - ), - device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "on", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 0.0, - Attribute.color_temperature: 4500, - }, - ), - ] - - -async def test_entity_state(hass: HomeAssistant, light_devices) -> None: - """Tests the state attributes properly match the light types.""" - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - - # Dimmer 1 - state = hass.states.get("light.dimmer_1") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert isinstance(state.attributes[ATTR_BRIGHTNESS], int) - assert state.attributes[ATTR_BRIGHTNESS] == 255 - - # Color Dimmer 1 - state = hass.states.get("light.color_dimmer_1") - assert state.state == "off" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - - # Color Dimmer 2 - state = hass.states.get("light.color_dimmer_2") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert ATTR_HS_COLOR not in state.attributes[ATTR_HS_COLOR] - assert isinstance(state.attributes[ATTR_COLOR_TEMP_KELVIN], int) - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4500 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Light 1", - [Capability.switch, Capability.switch_level], - { - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("light.light_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LIGHT) -async def test_turn_off(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_off", {"entity_id": "light.color_dimmer_2"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_off_with_transition(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully with transition.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2}, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_on", {ATTR_ENTITY_ID: "light.color_dimmer_1"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_brightness(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on to the specified brightness.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - { - ATTR_ENTITY_ID: "light.color_dimmer_1", - ATTR_BRIGHTNESS: 75, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 74 - - -async def test_turn_on_with_minimal_brightness( - hass: HomeAssistant, light_devices +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ) + ], + ), + ( + {ATTR_COLOR_TEMP_KELVIN: 4000}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + MAIN, + argument=4000, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_HS_COLOR: [350, 90]}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Command.SET_COLOR, + MAIN, + argument={"hue": 97.2222, "saturation": 90.0}, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_BRIGHTNESS: 50}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 0], + ) + ], + ), + ( + {ATTR_BRIGHTNESS: 50, ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 3], + ) + ], + ), + ], +) +async def test_turn_on_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], ) -> None: - """Test lights set to lowest brightness when converted scale would be zero. + """Test light turn on command.""" + await setup_integration(hass, mock_config_entry) - SmartThings light brightness is a percentage (0-100), but Home Assistant uses a - 0-255 scale. This tests if a really low value (1-2) is passed, we don't - set the level to zero, which turns off the lights in SmartThings. - """ - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_1", ATTR_BRIGHTNESS: 2}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 3 + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + ], + ), + ( + {ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[0, 3], + ) + ], + ), + ], +) +async def test_turn_off_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test light turn off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_HS_COLOR: (180, 50)}, + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_HS_COLOR] == (180, 50) + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color temp.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP_KELVIN: 3333}, - blocking=True, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Attribute.SWITCH, + "on", ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3333 + + assert hass.states.get("light.standing_light").state == STATE_ON -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the light updates when receiving a signal.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_brightness( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test brightness update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, ) - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the light is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 20, + ) + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 51 + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_hs( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hue/saturation update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 144.0, + 60, + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 72.0, + 60, + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_color_temp( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color temperature update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 3000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 3000 + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 2000 + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_modes( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode changes.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 50) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_mode_after_startup( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode after startup.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + RESTORE_DATA = { + ATTR_BRIGHTNESS: 178, + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (144.0, 60), + ATTR_MAX_COLOR_TEMP_KELVIN: 9000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2000, + ATTR_RGB_COLOR: (255, 128, 0), + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP, ColorMode.HS], + ATTR_XY_COLOR: (0.61, 0.35), + } + + mock_restore_cache_with_extra_data( + hass, ((State("light.standing_light", STATE_ON), RESTORE_DATA),) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP ) - config_entry = await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "light") - # Assert - assert hass.states.get("light.color_dimmer_2").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 3c2a2651fb9..28191eceb9a 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -1,129 +1,85 @@ -"""Test for the SmartThings lock platform. +"""Test for the SmartThings lock platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Lock_1", - [Capability.lock], - { - Attribute.lock: "unlocked", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("lock.lock_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LOCK) -async def test_lock(hass: HomeAssistant, device_factory) -> None: - """Test the lock locks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock]) - device.status.attributes[Attribute.lock] = Status( - "unlocked", - None, - { - "method": "Manual", - "codeId": None, - "codeName": "Code 1", - "lockName": "Front Door", - "usedCode": "Code 2", - }, - ) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_LOCK, Command.LOCK), + (SERVICE_UNLOCK, Command.UNLOCK), + ], +) +async def test_lock_unlock( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test lock and unlock command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - LOCK_DOMAIN, "lock", {"entity_id": "lock.lock_1"}, blocking=True + LOCK_DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.basement_door_lock"}, + blocking=True, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" - assert state.attributes["method"] == "Manual" - assert state.attributes["lock_state"] == "locked" - assert state.attributes["code_name"] == "Code 1" - assert state.attributes["used_code"] == "Code 2" - assert state.attributes["lock_name"] == "Front Door" - assert "code_id" not in state.attributes - - -async def test_unlock(hass: HomeAssistant, device_factory) -> None: - """Test the lock unlocks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - LOCK_DOMAIN, "unlock", {"entity_id": "lock.lock_1"}, blocking=True + devices.execute_device_command.assert_called_once_with( + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + command, + MAIN, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "unlocked" -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the lock updates when receiving a signal.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "unlocked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - await device.lock(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - config_entry = await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "lock") - # Assert - assert hass.states.get("lock.lock_1").state == STATE_UNAVAILABLE + await trigger_update( + hass, + devices, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + Attribute.LOCK, + "open", + ) + + assert hass.states.get("lock.basement_door_lock").state == LockState.UNLOCKED diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index a20db1aaae8..7ef287b9e96 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -1,52 +1,47 @@ -"""Test for the SmartThings scene platform. +"""Test for the SmartThings scene platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, scene +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Test the attributes of the entity are correct.""" - # Act - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - # Assert - entry = entity_registry.async_get("scene.test_scene") - assert entry - assert entry.unique_id == scene.scene_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SCENE) -async def test_scene_activate(hass: HomeAssistant, scene) -> None: - """Test the scene is activated.""" - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) +async def test_activate_scene( + hass: HomeAssistant, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test activating a scene.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( SCENE_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.test_scene"}, + {ATTR_ENTITY_ID: "scene.away"}, blocking=True, ) - state = hass.states.get("scene.test_scene") - assert state.attributes["icon"] == scene.icon - assert state.attributes["color"] == scene.color - assert state.attributes["location_id"] == scene.location_id - assert scene.execute.call_count == 1 - -async def test_unload_config_entry(hass: HomeAssistant, scene) -> None: - """Test the scene is removed when the config entry is unloaded.""" - # Arrange - config_entry = await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, SCENE_DOMAIN) - # Assert - assert hass.states.get("scene.test_scene").state == STATE_UNAVAILABLE + mock_smartthings.execute_scene.assert_called_once_with( + "743b0f37-89b8-476c-aedf-eea8ad8cd29d" + ) diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 021ee9cc810..c83950de9e9 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -1,307 +1,51 @@ -"""Test for the SmartThings sensors platform. +"""Test for the SmartThings sensors platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.sensor import ( - DEVICE_CLASSES, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASSES, -) -from homeassistant.components.smartthings import sensor -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - for capability, maps in sensor.CAPABILITY_TO_SENSORS.items(): - assert capability in CAPABILITIES, capability - for sensor_map in maps: - assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute - if sensor_map.device_class: - assert ( - sensor_map.device_class in DEVICE_CLASSES - ), sensor_map.device_class - if sensor_map.state_class: - assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class - - -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the sensor types.""" - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.sensor_1_battery") - assert state.state == "100" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Battery" - - -async def test_entity_three_axis_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: [100, 75, 25]} - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == "100" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} X Coordinate" - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == "75" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Y Coordinate" - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == "25" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Z Coordinate" - - -async def test_entity_three_axis_invalid_state( - hass: HomeAssistant, device_factory -) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: []} - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == STATE_UNKNOWN - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Sensor 1", - [Capability.battery], - { - Attribute.battery: 100, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("sensor.sensor_1_battery") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.battery}" - assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -async def test_energy_sensors_for_switch_device( +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_state_update( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - { - Attribute.switch: "off", - Attribute.power: 355, - Attribute.energy: 11.422, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.switch_1_energy_meter") - assert state - assert state.state == "11.422" - entry = entity_registry.async_get("sensor.switch_1_energy_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.switch_1_power_meter") - assert state - assert state.state == "355" - entry = entity_registry.async_get("sensor.switch_1_power_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.power}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_power_consumption_sensor( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, -) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "refrigerator", - [Capability.power_consumption_report], - { - Attribute.power_consumption: { - "energy": 1412002, - "deltaEnergy": 25, - "power": 109, - "powerEnergy": 24.304498331745464, - "persistedEnergy": 0, - "energySaved": 0, - "start": "2021-07-30T16:45:25Z", - "end": "2021-07-30T16:58:33Z", - }, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.refrigerator_energy") - assert state - assert state.state == "1412.002" - entry = entity_registry.async_get("sensor.refrigerator_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - state = hass.states.get("sensor.refrigerator_power") - assert state - assert state.state == "109" - assert state.attributes["power_consumption_start"] == "2021-07-30T16:45:25Z" - assert state.attributes["power_consumption_end"] == "2021-07-30T16:58:33Z" - entry = entity_registry.async_get("sensor.refrigerator_power") - assert entry - assert entry.unique_id == f"{device.device_id}.power_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - device = device_factory( - "vacuum", - [Capability.power_consumption_report], - { - Attribute.power_consumption: {}, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.vacuum_energy") - assert state - assert state.state == "unknown" - entry = entity_registry.async_get("sensor.vacuum_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.battery, Attribute.battery, 75 - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("sensor.sensor_1_battery") - assert state is not None - assert state.state == "75" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - config_entry = await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - # Assert - assert hass.states.get("sensor.sensor_1_battery").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py deleted file mode 100644 index c7861866fad..00000000000 --- a/tests/components/smartthings/test_smartapp.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Tests for the smartapp module.""" - -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 - -from pysmartthings import CAPABILITIES, AppEntity, Capability -import pytest - -from homeassistant.components.smartthings import smartapp -from homeassistant.components.smartthings.const import ( - CONF_REFRESH_TOKEN, - DATA_MANAGER, - DOMAIN, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_update_app(hass: HomeAssistant, app) -> None: - """Test update_app does not save if app is current.""" - await smartapp.update_app(hass, app) - assert app.save.call_count == 0 - - -async def test_update_app_updated_needed(hass: HomeAssistant, app) -> None: - """Test update_app updates when an app is needed.""" - mock_app = Mock(AppEntity) - mock_app.app_name = "Test" - - await smartapp.update_app(hass, mock_app) - - assert mock_app.save.call_count == 1 - assert mock_app.app_name == "Test" - assert mock_app.display_name == app.display_name - assert mock_app.description == app.description - assert mock_app.webhook_target_url == app.webhook_target_url - assert mock_app.app_type == app.app_type - assert mock_app.single_instance == app.single_instance - assert mock_app.classifications == app.classifications - - -async def test_smartapp_update_saves_token( - hass: HomeAssistant, smartthings_mock, location, device_factory -) -> None: - """Test update saves token.""" - # Arrange - entry = MockConfigEntry( - domain=DOMAIN, data={"installed_app_id": str(uuid4()), "app_id": str(uuid4())} - ) - entry.add_to_hass(hass) - app = Mock() - app.app_id = entry.data["app_id"] - request = Mock() - request.installed_app_id = entry.data["installed_app_id"] - request.auth_token = str(uuid4()) - request.refresh_token = str(uuid4()) - request.location_id = location.location_id - - # Act - await smartapp.smartapp_update(hass, request, None, app) - # Assert - assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token - - -async def test_smartapp_uninstall(hass: HomeAssistant, config_entry) -> None: - """Test the config entry is unloaded when the app is uninstalled.""" - config_entry.add_to_hass(hass) - app = Mock() - app.app_id = config_entry.data["app_id"] - request = Mock() - request.installed_app_id = config_entry.data["installed_app_id"] - - with patch.object(hass.config_entries, "async_remove") as remove: - await smartapp.smartapp_uninstall(hass, request, None, app) - assert remove.call_count == 1 - - -async def test_smartapp_webhook(hass: HomeAssistant) -> None: - """Test the smartapp webhook calls the manager.""" - manager = Mock() - manager.handle_request = AsyncMock(return_value={}) - hass.data[DOMAIN][DATA_MANAGER] = manager - request = Mock() - request.headers = [] - request.json = AsyncMock(return_value={}) - result = await smartapp.smartapp_webhook(hass, "", request) - - assert result.body == b"{}" - - -async def test_smartapp_sync_subscriptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization adds and removes and ignores unused.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.thermostat), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch, Capability.execute]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 - - -async def test_smartapp_sync_subscriptions_up_to_date( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 0 - assert smartthings_mock.create_subscription.call_count == 0 - - -async def test_smartapp_sync_subscriptions_limit_warning( - hass: HomeAssistant, - smartthings_mock, - device_factory, - subscription_factory, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test synchronization over the limit logs a warning.""" - smartthings_mock.subscriptions.return_value = [] - devices = [ - device_factory("", CAPABILITIES), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert ( - "Some device attributes may not receive push updates and there may be " - "subscription creation failures" in caplog.text - ) - - -async def test_smartapp_sync_subscriptions_handles_exceptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.delete_subscription.side_effect = Exception - smartthings_mock.create_subscription.side_effect = Exception - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.thermostat, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index fadd7600e87..a1e420a8edb 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -1,115 +1,89 @@ -"""Test for the SmartThings switch platform. +"""Test for the SmartThings switch platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from homeassistant.components.smartthings.const import MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch], - { - Attribute.switch: "on", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("switch.switch_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SWITCH) -async def test_turn_off(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "on"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.switch_1"}, blocking=True + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.2nd_floor_hallway"}, + blocking=True, ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + devices.execute_device_command.assert_called_once_with( + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", Capability.SWITCH, command, MAIN ) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.switch_1"}, blocking=True + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + await trigger_update( + hass, + devices, + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + Capability.SWITCH, + Attribute.SWITCH, + "off", ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the switch updates when receiving a signal.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "off"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the switch is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Switch 1", [Capability.switch], {Attribute.switch: "on"}) - config_entry = await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "switch") - # Assert - assert hass.states.get("switch.switch_1").state == STATE_UNAVAILABLE + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF diff --git a/tests/components/smarty/snapshots/test_binary_sensor.ambr b/tests/components/smarty/snapshots/test_binary_sensor.ambr index 2f943a25012..ad4b61f5070 100644 --- a/tests/components/smarty/snapshots/test_binary_sensor.ambr +++ b/tests/components/smarty/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_button.ambr b/tests/components/smarty/snapshots/test_button.ambr index 38849bd2b2e..b5b86c80beb 100644 --- a/tests/components/smarty/snapshots/test_button.ambr +++ b/tests/components/smarty/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_fan.ambr b/tests/components/smarty/snapshots/test_fan.ambr index 8ca95beeb86..2502bd6f09f 100644 --- a/tests/components/smarty/snapshots/test_fan.ambr +++ b/tests/components/smarty/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index b25cdb9dc3a..a292cc97f47 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/smarty/snapshots/test_sensor.ambr b/tests/components/smarty/snapshots/test_sensor.ambr index 2f713db7f83..c32740fa38c 100644 --- a/tests/components/smarty/snapshots/test_sensor.ambr +++ b/tests/components/smarty/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -148,6 +151,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -196,6 +200,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -244,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_switch.ambr b/tests/components/smarty/snapshots/test_switch.ambr index be1da7c6961..33c829adf31 100644 --- a/tests/components/smarty/snapshots/test_switch.ambr +++ b/tests/components/smarty/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py index a0bbf854699..0e65d288737 100644 --- a/tests/components/smhi/__init__.py +++ b/tests/components/smhi/__init__.py @@ -2,7 +2,6 @@ ENTITY_ID = "weather.smhi_test" TEST_CONFIG = { - "name": "test", "location": { "longitude": "17.84197", "latitude": "59.32624", @@ -11,5 +10,5 @@ TEST_CONFIG = { TEST_CONFIG_MIGRATE = { "name": "test", "longitude": "17.84197", - "latitude": "17.84197", + "latitude": "59.32624", } diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 4195d1e5d52..524aad873f9 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from smhi.smhi_lib import SmhiForecastException +from pysmhi import SmhiForecastException from homeassistant import config_entries from homeassistant.components.smhi.const import DOMAIN @@ -31,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -57,7 +57,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 0.0, "longitude": 0.0, }, - "name": "Home", } assert len(mock_setup_entry.mock_calls) == 1 @@ -67,7 +66,7 @@ async def test_form(hass: HomeAssistant) -> None: ) with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -93,7 +92,6 @@ async def test_form(hass: HomeAssistant) -> None: "latitude": 1.0, "longitude": 1.0, }, - "name": "Weather", } @@ -104,7 +102,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException, ): result2 = await hass.config_entries.flow.async_configure( @@ -124,7 +122,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: # Continue flow with new coordinates with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -150,7 +148,6 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: "latitude": 2.0, "longitude": 2.0, }, - "name": "Weather", } @@ -173,7 +170,7 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ): result2 = await hass.config_entries.flow.async_configure( @@ -201,8 +198,8 @@ async def test_reconfigure_flow( domain=DOMAIN, title="Home", unique_id="57.2898-13.6304", - data={"location": {"latitude": 57.2898, "longitude": 13.6304}, "name": "Home"}, - version=2, + data={"location": {"latitude": 57.2898, "longitude": 13.6304}}, + version=3, ) entry.add_to_hass(hass) @@ -221,7 +218,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException, ): result = await hass.config_entries.flow.async_configure( @@ -240,7 +237,7 @@ async def test_reconfigure_flow( with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -269,7 +266,6 @@ async def test_reconfigure_flow( "latitude": 58.2898, "longitude": 14.6304, }, - "name": "Home", } entity = entity_registry.async_get(entity.entity_id) assert entity diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index cfb386c8f6f..f301e684e3e 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,10 +1,9 @@ """Test SMHI component setup process.""" -from unittest.mock import patch - -from smhi.smhi_lib import APIURL_TEMPLATE +from pysmhi.const import API_POINT_FORECAST from homeassistant.components.smhi.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -18,11 +17,11 @@ async def test_setup_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test setup entry.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -36,11 +35,11 @@ async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test remove entry.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain=DOMAIN, title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -63,7 +62,7 @@ async def test_migrate_entry( api_response: str, ) -> None: """Test migrate entry data.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -77,7 +76,7 @@ async def test_migrate_entry( original_name="Weather", platform="smhi", supported_features=0, - unique_id="17.84197, 17.84197", + unique_id="59.32624, 17.84197", ) await hass.config_entries.async_setup(entry.entry_id) @@ -86,30 +85,27 @@ async def test_migrate_entry( state = hass.states.get(entity.entity_id) assert state - assert entry.version == 2 - assert entry.unique_id == "17.84197-17.84197" + assert entry.version == 3 + assert entry.unique_id == "59.32624-17.84197" + assert entry.data == TEST_CONFIG entity_get = entity_registry.async_get(entity.entity_id) - assert entity_get.unique_id == "17.84197, 17.84197" + assert entity_get.unique_id == "59.32624, 17.84197" -async def test_migrate_entry_failed( +async def test_migrate_from_future_version( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: - """Test migrate entry data that fails.""" - uri = APIURL_TEMPLATE.format( + """Test migrate entry not possible from future version.""" + uri = API_POINT_FORECAST.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE, version=4) entry.add_to_hass(hass) - assert entry.version == 1 + assert entry.version == 4 - with patch( - "homeassistant.config_entries.ConfigEntries.async_update_entry", - return_value=False, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert entry.version == 1 + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 1870d7b498a..a09a9689d52 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -4,28 +4,27 @@ from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +from pysmhi import SMHIForecast, SmhiForecastException +from pysmhi.const import API_POINT_FORECAST import pytest -from smhi.smhi_lib import APIURL_TEMPLATE, SmhiForecast, SmhiForecastException from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY -from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT +from homeassistant.components.smhi.weather import CONDITION_CLASSES from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_FORECAST_CONDITION, - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_VISIBILITY, - ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, UnitOfSpeed +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -44,17 +43,17 @@ async def test_setup_hass( snapshot: SnapshotAssertion, ) -> None: """Test for successfully setting up the smhi integration.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 # Testing the actual entity state for # deeper testing than normal unity test @@ -75,17 +74,17 @@ async def test_clear_night( """Test for successfully setting up the smhi integration.""" hass.config.latitude = "59.32624" hass.config.longitude = "17.84197" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response_night) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 state = hass.states.get(ENTITY_ID) @@ -103,106 +102,127 @@ async def test_clear_night( assert response == snapshot(name="clear-night_forecast") -async def test_properties_no_data(hass: HomeAssistant) -> None: +async def test_properties_no_data( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, +) -> None: """Test properties when no API data available.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + with patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_ATTRIBUTION] == "Swedish weather institute (SMHI)" - assert ATTR_WEATHER_HUMIDITY not in state.attributes - assert ATTR_WEATHER_PRESSURE not in state.attributes - assert ATTR_WEATHER_TEMPERATURE not in state.attributes - assert ATTR_WEATHER_VISIBILITY not in state.attributes - assert ATTR_WEATHER_WIND_SPEED not in state.attributes - assert ATTR_WEATHER_WIND_BEARING not in state.attributes - assert ATTR_WEATHER_CLOUD_COVERAGE not in state.attributes - assert ATTR_SMHI_THUNDER_PROBABILITY not in state.attributes - assert ATTR_WEATHER_WIND_GUST_SPEED not in state.attributes async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: """Test behaviour when unknown symbol from API.""" - data = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 1, 0, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 1, 0, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) - - data2 = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data2 = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 1, 12, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 1, 12, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) - - data3 = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data3 = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 2, 12, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 2, 0, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) testdata = [data, data2, data3] - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast_hour", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -229,55 +249,48 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @pytest.mark.parametrize("error", [SmhiForecastException(), TimeoutError()]) async def test_refresh_weather_forecast_retry( - hass: HomeAssistant, error: Exception + hass: HomeAssistant, + error: Exception, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, ) -> None: """Test the refresh weather forecast function.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) - now = dt_util.utcnow() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() with patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 1 - future = now + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 2 - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - assert mock_get_forecast.call_count == 3 - - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - # after three failed retries we stop retrying and go back to normal interval - assert mock_get_forecast.call_count == 3 - def test_condition_class() -> None: """Test condition class.""" @@ -352,12 +365,12 @@ async def test_custom_speed_unit( api_response: str, ) -> None: """Test Wind Gust speed with custom unit.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -389,12 +402,12 @@ async def test_forecast_services( snapshot: SnapshotAssertion, ) -> None: """Test multiple forecast.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -440,7 +453,7 @@ async def test_forecast_services( assert msg["type"] == "event" forecast1 = msg["event"]["forecast"] - assert len(forecast1) == 72 + assert len(forecast1) == 52 assert forecast1[0] == snapshot assert forecast1[6] == snapshot @@ -453,12 +466,12 @@ async def test_forecast_services_lack_of_data( snapshot: SnapshotAssertion, ) -> None: """Test forecast lacking data.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response_lack_data) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -498,12 +511,12 @@ async def test_forecast_service( service: str, ) -> None: """Test forecast service.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 665a55ba880..7a1b16f1d6b 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from pysmlight.exceptions import SmlightAuthError from pysmlight.sse import sseClient from pysmlight.web import CmdWrapper, Firmware, Info, Sensors import pytest @@ -18,7 +19,8 @@ from tests.common import ( load_json_object_fixture, ) -MOCK_HOST = "slzb-06.local" +MOCK_DEVICE_NAME = "slzb-06" +MOCK_HOST = "192.168.1.161" MOCK_USERNAME = "test-user" MOCK_PASSWORD = "test-pass" @@ -80,9 +82,16 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: ): api = smlight_mock.return_value api.host = MOCK_HOST - api.get_info.return_value = Info.from_dict( - load_json_object_fixture("info.json", DOMAIN) - ) + + def get_info_side_effect(*args, **kwargs) -> Info: + """Return the info.""" + if api.check_auth_needed.return_value and not api.authenticate.called: + raise SmlightAuthError + + return Info.from_dict(load_json_object_fixture("info.json", DOMAIN)) + + api.get_info.side_effect = get_info_side_effect + api.get_sensors.return_value = Sensors.from_dict( load_json_object_fixture("sensors.json", DOMAIN) ) @@ -91,7 +100,10 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return the firmware version.""" fw_list = [] if kwargs.get("mode") == "zigbee": - fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + if kwargs.get("zb_type") == 0: + fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + else: + fw_list = load_json_array_fixture("zb_firmware_router.json", DOMAIN) else: fw_list = load_json_array_fixture("esp_firmware.json", DOMAIN) diff --git a/tests/components/smlight/fixtures/esp_firmware.json b/tests/components/smlight/fixtures/esp_firmware.json index 6ea0e1a8b44..f0ee9eb989a 100644 --- a/tests/components/smlight/fixtures/esp_firmware.json +++ b/tests/components/smlight/fixtures/esp_firmware.json @@ -2,10 +2,10 @@ { "mode": "ESP", "type": null, - "notes": "CHANGELOG (Current 2.5.2 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", + "notes": "CHANGELOG (Current 2.7.5 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", "rev": "20240830", "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.5.2-ota.bin", - "ver": "v2.5.2", + "ver": "v2.7.5", "dev": false, "prod": true, "baud": null diff --git a/tests/components/smlight/fixtures/info-2.3.6.json b/tests/components/smlight/fixtures/info-2.3.6.json new file mode 100644 index 00000000000..e3defb4410e --- /dev/null +++ b/tests/components/smlight/fixtures/info-2.3.6.json @@ -0,0 +1,19 @@ +{ + "coord_mode": 0, + "device_ip": "192.168.1.161", + "fs_total": 3456, + "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-06p7", + "MAC": "AA:BB:CC:DD:EE:FF", + "model": "SLZB-06p7", + "ram_total": 296, + "sw_version": "v2.3.6", + "wifi_mode": 0, + "zb_flash_size": 704, + "zb_channel": 0, + "zb_hw": "CC2652P7", + "zb_ram_size": 152, + "zb_version": "20240314", + "zb_type": 0 +} diff --git a/tests/components/smlight/fixtures/info-MR1.json b/tests/components/smlight/fixtures/info-MR1.json new file mode 100644 index 00000000000..df1c0b0f789 --- /dev/null +++ b/tests/components/smlight/fixtures/info-MR1.json @@ -0,0 +1,41 @@ +{ + "coord_mode": 0, + "device_ip": "192.168.1.161", + "fs_total": 3456, + "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-MR1", + "MAC": "AA:BB:CC:DD:EE:FF", + "model": "SLZB-MR1", + "ram_total": 296, + "sw_version": "v2.7.3", + "wifi_mode": 0, + "zb_flash_size": 704, + "zb_channel": 0, + "zb_hw": "CC2652P7", + "zb_ram_size": 152, + "zb_version": "20240314", + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "EFR32MG21", + "zb_version": 20241127, + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + }, + { + "chip_index": 1, + "zb_hw": "CC2652P7", + "zb_version": 20240314, + "zb_type": 1, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] +} diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index e3defb4410e..b94fdc3d61c 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -15,5 +15,17 @@ "zb_hw": "CC2652P7", "zb_ram_size": 152, "zb_version": "20240314", - "zb_type": 0 + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "CC2652P7", + "zb_version": "20240314", + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] } diff --git a/tests/components/smlight/fixtures/zb_firmware.json b/tests/components/smlight/fixtures/zb_firmware.json index ca9d10f87ac..b35bb20d64e 100644 --- a/tests/components/smlight/fixtures/zb_firmware.json +++ b/tests/components/smlight/fixtures/zb_firmware.json @@ -3,24 +3,13 @@ "mode": "ZB", "type": 0, "notes": "SMLIGHT latest Coordinator release for CC2674P10 chips [16-Jul-2024]:
- +20dB TRANSMIT POWER SUPPORT;
- SDK 7.41 based (latest);
", - "rev": "20240716", + "rev": "20250201", "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", - "ver": "20240716", + "ver": "20250201", "dev": false, "prod": true, "baud": 115200 }, - { - "mode": "ZB", - "type": 1, - "notes": "SMLIGHT latest ROUTER release for CC2674P10 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use", - "rev": "20240716", - "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/zr-ZR_SLZB-06P10-20240716.bin", - "ver": "20240716", - "dev": false, - "prod": true, - "baud": 0 - }, { "mode": "ZB", "type": 0, diff --git a/tests/components/smlight/fixtures/zb_firmware_router.json b/tests/components/smlight/fixtures/zb_firmware_router.json new file mode 100644 index 00000000000..320fef89347 --- /dev/null +++ b/tests/components/smlight/fixtures/zb_firmware_router.json @@ -0,0 +1,13 @@ +[ + { + "mode": "ZB", + "type": 1, + "notes": "SMLIGHT latest ROUTER release for CC2652P7 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use - by downloading and installing this firmware, you agree to the aforementioned terms.", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 115200 + } +] diff --git a/tests/components/smlight/snapshots/test_binary_sensor.ambr b/tests/components/smlight/snapshots/test_binary_sensor.ambr index 8becf5b2567..edb2a914a5d 100644 --- a/tests/components/smlight/snapshots/test_binary_sensor.ambr +++ b/tests/components/smlight/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_diagnostics.ambr b/tests/components/smlight/snapshots/test_diagnostics.ambr index 97177de1704..5ee6cd19676 100644 --- a/tests/components/smlight/snapshots/test_diagnostics.ambr +++ b/tests/components/smlight/snapshots/test_diagnostics.ambr @@ -10,6 +10,24 @@ 'hostname': 'SLZB-06p7', 'legacy_api': 0, 'model': 'SLZB-06p7', + 'radios': list([ + dict({ + 'chip_index': 0, + 'radioModes': list([ + True, + True, + True, + False, + False, + ]), + 'zb_channel': 0, + 'zb_flash_size': 704, + 'zb_hw': 'CC2652P7', + 'zb_ram_size': 152, + 'zb_type': 0, + 'zb_version': '20240314', + }), + ]), 'ram_total': 296, 'sw_version': 'v2.3.6', 'wifi_mode': 0, diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 598166e537b..ba374199254 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -3,7 +3,8 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': 'http://slzb-06.local', + 'config_entries_subentries': , + 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( 'mac', diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 262ecfe1544..542338e4dbf 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -118,6 +120,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,6 +168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -269,6 +274,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -319,6 +325,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +384,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_switch.ambr b/tests/components/smlight/snapshots/test_switch.ambr index 733d002be0f..b748202a557 100644 --- a/tests/components/smlight/snapshots/test_switch.ambr +++ b/tests/components/smlight/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr index ed0085dcdc8..dc6b8f46ca5 100644 --- a/tests/components/smlight/snapshots/test_update.ambr +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -42,7 +43,7 @@ 'friendly_name': 'Mock Title Core firmware', 'in_progress': False, 'installed_version': 'v2.3.6', - 'latest_version': 'v2.5.2', + 'latest_version': 'v2.7.5', 'release_summary': None, 'release_url': None, 'skipped_version': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,7 +103,7 @@ 'friendly_name': 'Mock Title Zigbee firmware', 'in_progress': False, 'installed_version': '20240314', - 'latest_version': '20240716', + 'latest_version': '20250201', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index 3721ee815e6..51e9414c00e 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -45,6 +45,7 @@ async def test_buttons( mock_smlight_client: MagicMock, ) -> None: """Test creation of button entities.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) @@ -78,6 +79,7 @@ async def test_disabled_by_default_buttons( mock_smlight_client: MagicMock, ) -> None: """Test the disabled by default buttons.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) @@ -96,7 +98,8 @@ async def test_remove_router_reconnect( mock_smlight_client: MagicMock, ) -> None: """Test removal of orphaned router reconnect button.""" - save_mock = mock_smlight_client.get_info.return_value + save_mock = mock_smlight_client.get_info.side_effect + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER mock_config_entry = await setup_integration(hass, mock_config_entry) @@ -106,7 +109,7 @@ async def test_remove_router_reconnect( assert len(entities) == 4 assert entities[3].unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router" - mock_smlight_client.get_info.return_value = save_mock + mock_smlight_client.get_info.side_effect = save_mock freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 2fd39f75704..c8933029ce6 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -3,23 +3,25 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock +from pysmlight import Info from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest -from homeassistant.components import zeroconf from homeassistant.components.smlight.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry -DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], +DISCOVERY_INFO = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -27,9 +29,9 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( type="mock_type", ) -DISCOVERY_INFO_LEGACY = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], +DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -51,7 +53,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: MOCK_HOST, + CONF_HOST: "slzb-06p7.local", }, ) @@ -64,6 +66,46 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_flow_auth( + hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full manual user flow with authentication.""" + + mock_smlight_client.check_auth_needed.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "slzb-06p7.local", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "auth" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "SLZB-06p7" + assert result3["data"] == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } + assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_flow( hass: HomeAssistant, mock_smlight_client: MagicMock, @@ -75,7 +117,7 @@ async def test_zeroconf_flow( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -97,7 +139,7 @@ async def test_zeroconf_flow( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_zeroconf_flow_auth( @@ -112,7 +154,7 @@ async def test_zeroconf_flow_auth( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -143,7 +185,7 @@ async def test_zeroconf_flow_auth( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["context"]["source"] == "zeroconf" assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" - assert result3["title"] == "slzb-06" + assert result3["title"] == "SLZB-06p7" assert result3["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, @@ -151,12 +193,99 @@ async def test_zeroconf_flow_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 + + +async def test_zeroconf_unsupported_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort zeroconf flow if device unsupported.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_user_unsupported_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort user flow if unsupported device.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_user_unsupported_device_abort_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we abort user flow if unsupported device (with auth).""" + mock_smlight_client.check_auth_needed.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: MOCK_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info(model="SLZB-X") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" @pytest.mark.usefixtures("mock_smlight_client") async def test_user_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test we abort user flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -173,6 +302,44 @@ async def test_user_device_exists_abort( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_smlight_client") +async def test_user_flow_can_override_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test manual user flow can override discovery in progress.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + }, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["context"]["source"] == SOURCE_USER + assert result2["data"] == { + CONF_HOST: MOCK_HOST, + } + assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.usefixtures("mock_smlight_client") async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -239,7 +406,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_user_cannot_connect( @@ -276,7 +443,7 @@ async def test_user_cannot_connect( assert result2["title"] == "SLZB-06p7" assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 1 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_auth_cannot_connect( @@ -363,7 +530,7 @@ async def test_zeroconf_legacy_mac( data=DISCOVERY_INFO_LEGACY, ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -378,7 +545,77 @@ async def test_zeroconf_legacy_mac( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 2 + assert len(mock_smlight_client.get_info.mock_calls) == 3 + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_zeroconf_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DISCOVERY_INFO + service_info.ip_address = ip_address("192.168.1.164") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.164", + hostname="slzb-06", + macaddress="aabbccddeeff", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.161", + hostname="slzb-06", + macaddress="000000000000", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.161" async def test_reauth_flow( diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..692255a53e6 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,42 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + side_effect = mock_smlight_client.get_firmware_version.side_effect + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = side_effect + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, @@ -124,6 +165,7 @@ async def test_device_legacy_firmware( """Test device setup for old firmware version that dont support required API.""" LEGACY_VERSION = "v0.9.9" mock_smlight_client.get_sensors.side_effect = SmlightError + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( legacy_api=2, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF" ) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..86d19968910 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -4,13 +4,13 @@ from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from pysmlight import Firmware, Info +from pysmlight import Firmware, Info, Radio from pysmlight.const import Events as SmEvents from pysmlight.sse import MessageEvent import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import SCAN_FIRMWARE_INTERVAL +from homeassistant.components.smlight.const import DOMAIN, SCAN_FIRMWARE_INTERVAL from homeassistant.components.update import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, @@ -27,7 +27,12 @@ from homeassistant.helpers import entity_registry as er from . import get_mock_event_function from .conftest import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) from tests.typing import WebSocketGenerator pytestmark = [ @@ -62,12 +67,14 @@ MOCK_FIRMWARE_FAIL = MessageEvent( MOCK_FIRMWARE_NOTES = [ Firmware( - ver="v2.3.6", + ver="v2.7.2", mode="ESP", notes=None, ) ] +MOCK_RADIO = Radio(chip_index=1, zb_channel=0, zb_type=0, zb_version="20240716") + @pytest.fixture def platforms() -> list[Platform]: @@ -81,7 +88,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) @@ -103,7 +110,7 @@ async def test_update_firmware( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -125,8 +132,9 @@ async def test_update_firmware( event_function(MOCK_FIRMWARE_DONE) + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( - sw_version="v2.5.2", + sw_version="v2.7.5", ) freezer.tick(timedelta(seconds=5)) @@ -135,8 +143,51 @@ async def test_update_firmware( state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.7.5" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" + + +async def test_update_zigbee2_firmware( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test update of zigbee2 firmware where available.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_zigbee_firmware_2" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240314" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) + + event_function(MOCK_FIRMWARE_DONE) + with patch( + "homeassistant.components.smlight.update.get_radio", return_value=MOCK_RADIO + ): + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" async def test_update_legacy_firmware_v2( @@ -146,6 +197,7 @@ async def test_update_legacy_firmware_v2( mock_smlight_client: MagicMock, ) -> None: """Test firmware update for legacy v2 firmware.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.0.18", legacy_api=1, @@ -156,7 +208,7 @@ async def test_update_legacy_firmware_v2( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.0.18" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -171,8 +223,9 @@ async def test_update_legacy_firmware_v2( event_function(MOCK_FIRMWARE_DONE) + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( - sw_version="v2.5.2", + sw_version="v2.7.5", ) freezer.tick(SCAN_FIRMWARE_INTERVAL) @@ -181,8 +234,8 @@ async def test_update_legacy_firmware_v2( state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.7.5" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" async def test_update_firmware_failed( @@ -196,7 +249,7 @@ async def test_update_firmware_failed( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" await hass.services.async_call( PLATFORM, @@ -233,7 +286,7 @@ async def test_update_reboot_timeout( state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" - assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.7.5" with ( patch( @@ -267,18 +320,30 @@ async def test_update_reboot_timeout( mock_warning.assert_called_once() +@pytest.mark.parametrize( + "entity_id", + [ + "update.mock_title_core_firmware", + "update.mock_title_zigbee_firmware", + "update.mock_title_zigbee_firmware_2", + ], +) async def test_update_release_notes( hass: HomeAssistant, + entity_id: str, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test firmware release notes.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) await setup_integration(hass, mock_config_entry) ws_client = await hass_ws_client(hass) await hass.async_block_till_done() - entity_id = "update.mock_title_core_firmware" state = hass.states.get(entity_id) assert state @@ -294,16 +359,30 @@ async def test_update_release_notes( result = await ws_client.receive_json() assert result["result"] is not None + +async def test_update_blank_release_notes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test firmware missing release notes.""" + + entity_id = "update.mock_title_core_firmware" mock_smlight_client.get_firmware_version.side_effect = None mock_smlight_client.get_firmware_version.return_value = MOCK_FIRMWARE_NOTES - freezer.tick(SCAN_FIRMWARE_INTERVAL) - async_fire_time_changed(hass) + await setup_integration(hass, mock_config_entry) + ws_client = await hass_ws_client(hass) await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + await ws_client.send_json( { - "id": 2, + "id": 1, "type": "update/release_notes", "entity_id": entity_id, } diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py new file mode 100644 index 00000000000..f8529251720 --- /dev/null +++ b/tests/components/snoo/__init__.py @@ -0,0 +1,38 @@ +"""Tests for the Happiest Baby Snoo integration.""" + +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def create_entry( + hass: HomeAssistant, +) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "sample", + }, + # This is also gotten from the fake jwt + unique_id="123e4567-e89b-12d3-a456-426614174000", + version=1, + ) + entry.add_to_hass(hass) + return entry + + +async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: + """Set up the Snoo integration in Home Assistant.""" + + entry = create_entry(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py new file mode 100644 index 00000000000..33642e67ff5 --- /dev/null +++ b/tests/components/snoo/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Happiest Baby Snoo tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.snoo import Snoo + +from .const import MOCK_AMAZON_AUTH, MOCK_SNOO_AUTH, MOCK_SNOO_DEVICES + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snoo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class MockedSnoo(Snoo): + """Mock the Snoo object.""" + + def __init__(self, email, password, clientsession) -> None: + """Set up a Mocked Snoo.""" + super().__init__(email, password, clientsession) + self.auth_error = None + + async def subscribe(self, device: SnooDevice, function): + """Mock the subscribe function.""" + return AsyncMock() + + async def send_command(self, command: str, device: SnooDevice, **kwargs): + """Mock the send command function.""" + return AsyncMock() + + async def authorize(self): + """Do normal auth flow unless error is patched.""" + if self.auth_error: + raise self.auth_error + return await super().authorize() + + def set_auth_error(self, error: Exception | None): + """Set an error for authentication.""" + self.auth_error = error + + async def auth_amazon(self): + """Mock the amazon auth.""" + return MOCK_AMAZON_AUTH + + async def auth_snoo(self, id_token): + """Mock the snoo auth.""" + return MOCK_SNOO_AUTH + + async def schedule_reauthorization(self, snoo_expiry: int): + """Mock scheduling reauth.""" + return AsyncMock() + + async def get_devices(self) -> list[SnooDevice]: + """Move getting devices.""" + return [SnooDevice.from_dict(dev) for dev in MOCK_SNOO_DEVICES] + + +@pytest.fixture(name="bypass_api") +def bypass_api() -> MockedSnoo: + """Bypass the Snoo api.""" + api = MockedSnoo("email", "password", AsyncMock()) + with ( + patch("homeassistant.components.snoo.Snoo", return_value=api), + patch("homeassistant.components.snoo.config_flow.Snoo", return_value=api), + ): + yield api diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py new file mode 100644 index 00000000000..c5d53780fa1 --- /dev/null +++ b/tests/components/snoo/const.py @@ -0,0 +1,34 @@ +"""Snoo constants for testing.""" + +MOCK_AMAZON_AUTH = { + # This is a JWT with random values. + "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" + "LTQ3ODktOTBhYi1jZGVmMDEyMzQ1NjciLCJpc3MiOiJodHRwczovL2NvZ25pdG8taWRwLnVzLXdlc3Qt" + "Mi5hbWF6b25hd3MuY29tL3VzLXdlc3QtMl9FeGFtcGxlVXNlclBvb2xJZCIsImNsaWVudF9pZCI6ImFiY" + "2RlZmdoMTIzNDU2Nzg5MGFiY2RlZmdoMTIiLCJvcmlnaW5fanRpIjoiYjhkOWUwZjEtMmczaC00aTVqLT" + "ZrN2wtOG05bjBvMXAycTNyIiwiZXZlbnRfaWQiOiJmMGcxaDJpMy00ajVrLTZsN20tOG45by0wcDFxMnI" + "zczR0NXUiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6ImF3cy5jb2duaXRvLnNpZ25pbi51c2Vy" + "LmFkbWluIiwiYXV0aF90aW1lIjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwM" + "DAwMCwianRpIjoidjZ3N3g4eTktMHoxYS0yYjNjLTRkNWUtNmY3ZzhoOWkwajFrIiwidXNlcm5hbWUiOi" + "IxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifQ.zH5vy5itWot_5-rdJgYoygeKx696" + "Uge46zxXMhdn5RE", + "IdToken": "random_id", + "RefreshToken": "refresh_token", +} + +MOCK_SNOO_AUTH = {"expiresIn": 10800, "snoo": {"token": "random_snoo_token"}} + +MOCK_SNOO_DEVICES = [ + { + "serialNumber": "random_num", + "deviceType": 1, + "firmwareVersion": 1.0, + "babyIds": ["35235-211235-dfasdf-32523"], + "name": "Test Snoo", + "presence": {}, + "presenceIoT": {}, + "awsIoT": {}, + "lastSSID": {}, + "provisionedAt": "random_time", + } +] diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py new file mode 100644 index 00000000000..ffdfb22142d --- /dev/null +++ b/tests/components/snoo/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Happiest Baby Snoo config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException + +from homeassistant import config_entries +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import create_entry +from .conftest import MockedSnoo + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: MockedSnoo +) -> None: + """Test we create the entry successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "123e4567-e89b-12d3-a456-426614174000" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (InvalidSnooAuth, "invalid_auth"), + (SnooAuthException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_issues( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + bypass_api: MockedSnoo, + exception, + error_msg, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # Set Authorize to fail. + bypass_api.set_auth_error(exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + # Reset auth back to the original + bypass_api.set_auth_error(None) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + 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.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_account_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api +) -> None: + """Ensure we abort if the config flow already exists.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py new file mode 100644 index 00000000000..06f420b6518 --- /dev/null +++ b/tests/components/snoo/test_init.py @@ -0,0 +1,14 @@ +"""Test init for Snoo.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_init_integration +from .conftest import MockedSnoo + + +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: MockedSnoo) -> None: + """Test a successful setup entry.""" + entry = await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert entry.state == ConfigEntryState.LOADED diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index e0f1bc2623c..6aef72ebbd5 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'solarlog', 'unique_id': None, 'version': 1, diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 06bc01f9d39..c51f7627efc 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -224,6 +228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -329,6 +335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -437,6 +445,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -494,6 +503,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -551,6 +561,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -662,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -716,6 +729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -765,6 +779,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -814,6 +829,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -865,6 +881,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -916,6 +933,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -967,6 +985,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1037,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1072,6 +1092,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1123,6 +1144,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1174,6 +1196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1231,6 +1254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,6 +1312,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1345,6 +1370,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1397,6 +1423,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/somfy_mylink/test_config_flow.py b/tests/components/somfy_mylink/test_config_flow.py index 9084d988ec9..b7007f27fa9 100644 --- a/tests/components/somfy_mylink/test_config_flow.py +++ b/tests/components/somfy_mylink/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.somfy_mylink.const import ( CONF_REVERSED_TARGET_IDS, CONF_SYSTEM_ID, @@ -14,6 +13,7 @@ from homeassistant.components.somfy_mylink.const import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -263,7 +263,7 @@ async def test_form_user_already_configured_from_dhcp(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -287,7 +287,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", @@ -302,7 +302,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="somfy_eeff", diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 3ccff4c88ba..78f03e8b6de 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -49,7 +49,7 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_commands") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Commands" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "commands" assert state.state == "2" state = hass.states.get("sensor.sonarr_disk_space") @@ -60,25 +60,25 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_queue") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("The Andy Griffith Show S01E01") == "100.00%" assert state.state == "1" state = hass.states.get("sensor.sonarr_shows") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Series" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "series" assert state.attributes.get("The Andy Griffith Show") == "0/0 Episodes" assert state.state == "1" state = hass.states.get("sensor.sonarr_upcoming") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers") == "S04E11" assert state.state == "1" state = hass.states.get("sensor.sonarr_wanted") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26T17:30:00-08:00" assert ( state.attributes.get("The Andy Griffith Show S01E01") diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py index 5215e9b3c0e..0ae2ab596db 100644 --- a/tests/components/songpal/test_config_flow.py +++ b/tests/components/songpal/test_config_flow.py @@ -4,7 +4,6 @@ import copy import dataclasses from unittest.mock import patch -from homeassistant.components import ssdp from homeassistant.components.songpal.const import CONF_ENDPOINT, DOMAIN from homeassistant.config_entries import ( SOURCE_IMPORT, @@ -15,6 +14,11 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import ( CONF_DATA, @@ -30,13 +34,13 @@ from tests.common import MockConfigEntry UDN = "uuid:1234" -SSDP_DATA = ssdp.SsdpServiceInfo( +SSDP_DATA = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}:52323/dmr.xml", upnp={ - ssdp.ATTR_UPNP_UDN: UDN, - ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, + ATTR_UPNP_UDN: UDN, + ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, "X_ScalarWebAPI_DeviceInfo": { "X_ScalarWebAPI_BaseURL": ENDPOINT, "X_ScalarWebAPI_ServiceList": { diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 04b35e2c021..e22f18c6d77 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -18,11 +18,13 @@ from soco.data_structures import ( ) from soco.events_base import Event as SonosEvent -from homeassistant.components import ssdp, zeroconf +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture @@ -108,7 +110,7 @@ class SonosMockEvent: @pytest.fixture def zeroconf_payload(): """Return a default zeroconf payload.""" - return zeroconf.ZeroconfServiceInfo( + return ZeroconfServiceInfo( ip_address=ip_address("192.168.4.2"), ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", @@ -335,17 +337,17 @@ def discover_fixture(soco): def do_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: callback( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ssdp.SsdpChange.ALIVE, @@ -578,13 +580,19 @@ def alarm_clock_fixture_extended(): return alarm_clock +@pytest.fixture(name="speaker_model") +def speaker_model_fixture(request: pytest.FixtureRequest): + """Create fixture for the speaker model.""" + return getattr(request, "param", "Model Name") + + @pytest.fixture(name="speaker_info") -def speaker_info_fixture(): +def speaker_info_fixture(speaker_model): """Create speaker_info fixture.""" return { "zone_name": "Zone A", "uid": "RINCON_test", - "model_name": "Model Name", + "model_name": speaker_model, "model_number": "S12", "hardware_version": "1.20.1.6-1.1", "software_version": "49.2-64250", diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 8ef298de3db..7f4681d8915 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 141013dec20..70605092da1 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -6,17 +6,18 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries -from homeassistant.components import ssdp, zeroconf from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component async def test_user_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we get the user initiated form.""" @@ -84,7 +85,7 @@ async def test_user_form_already_created(hass: HomeAssistant) -> None: async def test_zeroconf_form( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we pass Zeroconf discoveries to the manager.""" @@ -128,12 +129,12 @@ async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_location=f"http://{soco.ip_address}/", ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", upnp={ - ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + ATTR_UPNP_UDN: f"uuid:{soco.uid}", }, ), ) @@ -173,7 +174,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.107"), ip_addresses=[ip_address("192.168.1.107")], port=1443, @@ -221,7 +222,7 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: async def test_zeroconf_form_not_sonos( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test we abort on non-sonos devices.""" mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 36a6571f3b0..a7ad2f4cb82 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import sonos, zeroconf +from homeassistant.components import sonos from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( DATA_SONOS_DISCOVERY_MANAGER, @@ -19,8 +19,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockSoCo, SoCoMockFactory @@ -28,7 +29,7 @@ from tests.common import async_fire_time_changed async def test_creating_entry_sets_up_media_player( - hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo ) -> None: """Test setting up Sonos loads the media player.""" diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 63b2c8889ec..cec40c997a7 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -10,6 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -1205,3 +1206,27 @@ async def test_media_get_queue( ) soco_mock.get_queue.assert_called_with(max_items=0) assert result == snapshot + + +@pytest.mark.parametrize( + ("speaker_model", "source_list"), + [ + ("Sonos Arc Ultra", [SOURCE_TV]), + ("Sonos Arc", [SOURCE_TV]), + ("Sonos Playbar", [SOURCE_TV]), + ("Sonos Connect", [SOURCE_LINEIN]), + ("Sonos Play:5", [SOURCE_LINEIN]), + ("Sonos Amp", [SOURCE_LINEIN, SOURCE_TV]), + ("Sonos Era", None), + ], + indirect=["speaker_model"], +) +async def test_media_source_list( + hass: HomeAssistant, + async_autosetup_sonos, + speaker_model: str, + source_list: list[str] | None, +) -> None: + """Test the mapping between the speaker model name and source_list.""" + state = hass.states.get("media_player.zone_a") + assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == source_list diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 264049ab5fc..fe524da5603 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -8,11 +8,11 @@ import requests_mock from requests_mock import ANY, Mocker from homeassistant.components.soundtouch.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import DEVICE_1_ID, DEVICE_1_IP, DEVICE_1_NAME diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 2e20aaa259c..1dd30c425b3 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.speedtestdotnet.coordinator import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/spider/test_init.py b/tests/components/spider/test_init.py index 6d1d87cfa6a..f28fc9d5871 100644 --- a/tests/components/spider/test_init.py +++ b/tests/components/spider/test_init.py @@ -1,7 +1,11 @@ """Tests for the Spider integration.""" from homeassistant.components.spider import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_spider_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_spider_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/spotify/snapshots/test_media_player.ambr b/tests/components/spotify/snapshots/test_media_player.ambr index 9692d59cfd1..74dbcb50f92 100644 --- a/tests/components/spotify/snapshots/test_media_player.ambr +++ b/tests/components/spotify/snapshots/test_media_player.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -79,6 +80,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index cb942a63568..24c0e1d41d9 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -7,18 +7,18 @@ from unittest.mock import MagicMock, patch import pytest from spotifyaio import SpotifyConnectionError -from homeassistant.components import zeroconf from homeassistant.components.spotify.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( +BLANK_ZEROCONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("1.2.3.4"), ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", diff --git a/tests/components/spotify/test_media_player.py b/tests/components/spotify/test_media_player.py index 55e0ea8f1d8..456af43d411 100644 --- a/tests/components/spotify/test_media_player.py +++ b/tests/components/spotify/test_media_player.py @@ -641,3 +641,147 @@ async def test_no_album_images( state = hass.states.get("media_player.spotify_spotify_1") assert state assert ATTR_ENTITY_PICTURE not in state.attributes + + +@pytest.mark.usefixtures("setup_credentials") +async def test_normal_polling_interval( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + await setup_integration(hass, mock_config_entry) + + assert mock_spotify.return_value.get_playback.return_value.is_playing is True + assert ( + mock_spotify.return_value.get_playback.return_value.progress_ms + - mock_spotify.return_value.get_playback.return_value.item.duration_ms + < 30000 + ) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + freezer.move_to("2023-10-21") + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 50000 + + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval_handles_errors( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + mock_spotify.return_value.get_playback.side_effect = SpotifyConnectionError + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + +@pytest.mark.usefixtures("setup_credentials") +async def test_smart_polling_interval_handles_paused( + hass: HomeAssistant, + mock_spotify: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Spotify media player polling interval.""" + mock_spotify.return_value.get_playback.return_value.progress_ms = 10000 + mock_spotify.return_value.get_playback.return_value.item.duration_ms = 30000 + mock_spotify.return_value.get_playback.return_value.is_playing = False + + await setup_integration(hass, mock_config_entry) + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() + + freezer.tick(timedelta(seconds=21)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_not_called() + + freezer.tick(timedelta(seconds=9)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_spotify.return_value.get_playback.assert_called_once() + mock_spotify.return_value.get_playback.reset_mock() diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 7b007114420..429c3b62087 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -33,6 +33,9 @@ from homeassistant.helpers.device_registry import format_mac # from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +CONF_VOLUME_STEP = "volume_step" +TEST_VOLUME_STEP = 10 + TEST_HOST = "1.2.3.4" TEST_PORT = "9000" TEST_USE_HTTPS = False @@ -109,11 +112,19 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PORT: TEST_PORT, const.CONF_HTTPS: TEST_USE_HTTPS, }, + options={ + CONF_VOLUME_STEP: TEST_VOLUME_STEP, + }, ) config_entry.add_to_hass(hass) return config_entry +async def mock_async_play_announcement(media_id: str) -> bool: + """Mock the announcement.""" + return True + + async def mock_async_browse( media_type: MediaType, limit: int, browse_id: tuple | None = None ) -> dict | None: @@ -121,6 +132,7 @@ async def mock_async_browse( child_types = { "favorites": "favorites", "new music": "album", + "album artists": "artists", "albums": "album", "album": "track", "genres": "genre", @@ -131,6 +143,9 @@ async def mock_async_browse( "title": "title", "playlists": "playlist", "playlist": "title", + "apps": "app", + "radios": "app", + "app-fakecommand": "track", } fake_items = [ { @@ -141,15 +156,19 @@ async def mock_async_browse( "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", - "isaudio": True, + "isaudio": False, "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 3", @@ -158,6 +177,19 @@ async def mock_async_browse( "isaudio": True, "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + }, + { + "title": "Fake Invalid Item 1", + "id": FAKE_VALID_ITEM_ID + "invalid_3", + "hasitems": media_type == "favorites", + "isaudio": True, + "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, + "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + "type": "text", }, ] @@ -187,7 +219,10 @@ async def mock_async_browse( "items": fake_items, } return None - if media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values(): + if ( + media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values() + or media_type == "app-fakecommand" + ): return { "title": media_type, "items": fake_items, @@ -216,6 +251,14 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.generate_image_url_from_track_id = MagicMock( return_value="http://lms.internal:9000/html/images/favorites.png" ) + mock_player.set_announce_volume = MagicMock(return_value=True) + mock_player.set_announce_timeout = MagicMock(return_value=True) + mock_player.async_play_announcement = AsyncMock( + side_effect=mock_async_play_announcement + ) + mock_player.generate_image_url = MagicMock( + return_value="http://lms.internal:9000/html/images/favorites.png" + ) mock_player.name = TEST_PLAYER_NAME mock_player.player_id = uuid mock_player.mode = "stop" diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index ddd5b9868a1..34d6ae16af8 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -43,6 +44,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -63,7 +65,7 @@ 'original_name': None, 'platform': 'squeezebox', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -86,7 +88,7 @@ }), 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index f2c9636c470..cae3672061b 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,11 +6,16 @@ from unittest.mock import patch from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components import dhcp -from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN +from homeassistant.components.squeezebox.const import ( + CONF_BROWSE_LIMIT, + CONF_HTTPS, + CONF_VOLUME_STEP, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -19,6 +24,8 @@ HOST2 = "2.2.2.2" PORT = 9000 UUID = "test-uuid" UNKNOWN_ERROR = "1234" +BROWSE_LIMIT = 10 +VOLUME_STEP = 1 async def mock_discover(_discovery_callback): @@ -87,6 +94,45 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_options_form(hass: HomeAssistant) -> None: + """Test we can configure options.""" + entry = MockConfigEntry( + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + unique_id=UUID, + domain=DOMAIN, + options={CONF_BROWSE_LIMIT: 1000, CONF_VOLUME_STEP: 5}, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # simulate manual input of options + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_BROWSE_LIMIT: BROWSE_LIMIT, CONF_VOLUME_STEP: VOLUME_STEP}, + ) + + # put some meaningful asserts here + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_BROWSE_LIMIT: BROWSE_LIMIT, + CONF_VOLUME_STEP: VOLUME_STEP, + } + + async def test_user_form_timeout(hass: HomeAssistant) -> None: """Test we handle server search timeout.""" with ( @@ -143,27 +189,67 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "edit"} - ) async def patch_async_query(self, *args): self.http_status = HTTPStatus.UNAUTHORIZED return False - with patch("pysqueezebox.Server.async_query", new=patch_async_query): + with ( + patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), + patch( + "homeassistant.components.squeezebox.async_setup_entry", + return_value=True, + ), + patch( + "homeassistant.components.squeezebox.config_flow.async_discover", + mock_discover, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "edit" + + with patch( + "homeassistant.components.squeezebox.config_flow.Server.async_query", + new=patch_async_query, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: HOST, CONF_PORT: PORT, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, }, ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + } async def test_form_validate_exception(hass: HomeAssistant) -> None: @@ -293,7 +379,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -315,7 +401,7 @@ async def test_dhcp_discovery_no_server_found(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", @@ -334,7 +420,7 @@ async def test_dhcp_discovery_existing_player(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.1.1.1", macaddress="aabbccddeeff", hostname="any", diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index c03c1b6344d..7b11ef30a87 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -19,6 +19,8 @@ from homeassistant.components.squeezebox.browse_media import ( from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from .conftest import FAKE_VALID_ITEM_ID + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -66,56 +68,144 @@ async def test_async_browse_media_root( assert item["title"] == LIBRARY[idx] +@pytest.mark.parametrize( + ("category", "child_count"), + [ + ("Favorites", 4), + ("Artists", 4), + ("Albums", 4), + ("Playlists", 4), + ("Genres", 4), + ("New Music", 4), + ("Album Artists", 4), + ("Apps", 3), + ("Radios", 3), + ], +) async def test_async_browse_media_with_subitems( hass: HomeAssistant, config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, + category: str, + child_count: int, ) -> None: """Test each category with subitems.""" - for category in ( - "Favorites", - "Artists", - "Albums", - "Playlists", - "Genres", - "New Music", + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, ): - with patch( - "homeassistant.components.squeezebox.browse_media.is_internal_request", - return_value=False, - ): - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": "", - "media_content_type": category, - } - ) - response = await client.receive_json() - assert response["success"] - category_level = response["result"] - assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] - assert category_level["children"][0]["title"] == "Fake Item 1" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"] + assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] + assert category_level["children"][0]["title"] == "Fake Item 1" + assert len(category_level["children"]) == child_count - # Look up a subitem - search_type = category_level["children"][0]["media_content_type"] - search_id = category_level["children"][0]["media_content_id"] - await client.send_json( + # Look up a subitem + search_type = category_level["children"][0]["media_content_type"] + search_id = category_level["children"][0]["media_content_id"] + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": search_id, + "media_content_type": search_type, + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["title"] == "Fake Item 1" + + +async def test_async_browse_media_for_apps( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing for app category.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + # Look up a subitem + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "app-fakecommand", + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["children"][0]["title"] == "Fake Item 1" + assert "Fake Invalid Item 1" not in search + + +async def test_generate_playlist_for_app( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the generate_playlist for app-fakecommand media type.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + try: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { - "id": 2, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": search_id, - "media_content_type": search_type, - } + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "app-fakecommand", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + }, + blocking=True, ) - response = await client.receive_json() - assert response["success"] - search = response["result"] - assert search["title"] == "Fake Item 1" + except BrowseError: + pytest.fail("generate_playlist fails for app") async def test_async_browse_tracks( @@ -142,7 +232,7 @@ async def test_async_browse_tracks( assert response["success"] tracks = response["result"] assert tracks["title"] == "titles" - assert len(tracks["children"]) == 3 + assert len(tracks["children"]) == 4 async def test_async_browse_error( diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 080a2161b4d..f3292f1b469 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -10,9 +10,11 @@ from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_EXTRA, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, @@ -31,6 +33,8 @@ from homeassistant.components.media_player import ( RepeatMode, ) from homeassistant.components.squeezebox.const import ( + ATTR_ANNOUNCE_TIMEOUT, + ATTR_ANNOUNCE_VOLUME, DISCOVERY_INTERVAL, DOMAIN, PLAYER_UPDATE_INTERVAL, @@ -68,7 +72,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow -from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC +from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC, TEST_VOLUME_STEP from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -183,26 +187,32 @@ async def test_squeezebox_volume_up( hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test volume up service call.""" + configured_player.volume = 50 await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_volume.assert_called_once_with("+5") + configured_player.async_set_volume.assert_called_once_with( + str(configured_player.volume + TEST_VOLUME_STEP) + ) async def test_squeezebox_volume_down( hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test volume down service call.""" + configured_player.volume = 50 await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_volume.assert_called_once_with("-5") + configured_player.async_set_volume.assert_called_once_with( + str(configured_player.volume - TEST_VOLUME_STEP) + ) async def test_squeezebox_volume_set( @@ -430,6 +440,115 @@ async def test_squeezebox_play( configured_player.async_play.assert_called_once() +async def test_squeezebox_play_media_with_announce( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test play service call with announce.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + configured_player.async_load_url.assert_called_once_with( + FAKE_VALID_ITEM_ID, "announce" + ) + + +@pytest.mark.parametrize( + "announce_volume", + ["0.2", 0.2], +) +async def test_squeezebox_play_media_with_announce_volume( + hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int +) -> None: + """Test play service call with announce.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_VOLUME: announce_volume}, + }, + blocking=True, + ) + configured_player.set_announce_volume.assert_called_once_with(20) + configured_player.async_load_url.assert_called_once_with( + FAKE_VALID_ITEM_ID, "announce" + ) + + +@pytest.mark.parametrize("announce_volume", ["1.1", 1.1, "text", "-1", -1, 0, "0"]) +async def test_squeezebox_play_media_with_announce_volume_invalid( + hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int +) -> None: + """Test play service call with announce and volume zero.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_VOLUME: announce_volume}, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("announce_timeout", ["-1", "text", -1, 0, "0"]) +async def test_squeezebox_play_media_with_announce_timeout_invalid( + hass: HomeAssistant, configured_player: MagicMock, announce_timeout: str | int +) -> None: + """Test play service call with announce and invalid timeout.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_TIMEOUT: announce_timeout}, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("announce_timeout", ["100", 100]) +async def test_squeezebox_play_media_with_announce_timeout( + hass: HomeAssistant, configured_player: MagicMock, announce_timeout: str | int +) -> None: + """Test play service call with announce.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_TIMEOUT: announce_timeout}, + }, + blocking=True, + ) + configured_player.set_announce_timeout.assert_called_once_with(100) + configured_player.async_load_url.assert_called_once_with( + FAKE_VALID_ITEM_ID, "announce" + ) + + async def test_squeezebox_play_pause( hass: HomeAssistant, configured_player: MagicMock ) -> None: diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index b612bc9f3f3..b1d5b958d47 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -12,7 +12,7 @@ import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MOCK_USAGE, TEST_CONFIG_HOME diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 7dc0f0095d4..56623b51bb5 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from ipaddress import IPv4Address +from typing import Any from unittest.mock import ANY, AsyncMock, patch from async_upnp_client.server import UpnpServer @@ -19,13 +20,32 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.ssdp import ( + ATTR_NT, + ATTR_ST, + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_MODEL_DESCRIPTION, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_MODEL_URL, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_SERVICE_LIST, + ATTR_UPNP_UDN, + ATTR_UPNP_UPC, + SsdpServiceInfo, +) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, MockModule, async_fire_time_changed, + import_and_test_deprecated_constant, mock_integration, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -74,7 +94,7 @@ async def test_ssdp_flow_dispatched_on_st( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -83,7 +103,7 @@ async def test_ssdp_flow_dispatched_on_st( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -118,7 +138,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), "source": config_entries.SOURCE_SSDP, } - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_usn == "uuid:mock-udn::mock-st" @@ -127,7 +147,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert mock_call_data.ssdp_udn == ANY assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == {"mock-domain"} - assert mock_call_data.upnp == {ssdp.ATTR_UPNP_UDN: "uuid:mock-udn"} + assert mock_call_data.upnp == {ATTR_UPNP_UDN: "uuid:mock-udn"} assert "Failed to fetch ssdp data" not in caplog.text @@ -227,8 +247,8 @@ async def test_scan_match_upnp_devicedesc_devicetype( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Paulus", } ] }, @@ -270,8 +290,8 @@ async def test_scan_not_all_present( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_MANUFACTURER: "Not-Paulus", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_MANUFACTURER: "Not-Paulus", } ] }, @@ -455,8 +475,8 @@ async def test_discovery_from_advertisement_sets_ssdp_st( assert discovery_info.ssdp_headers["nts"] == "ssdp:alive" assert discovery_info.ssdp_headers["_timestamp"] == ANY assert discovery_info.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:mock-udn", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:mock-udn", } @@ -555,7 +575,7 @@ async def test_scan_with_registered_callback( assert async_match_any_callback.call_count == 1 assert async_not_matching_integration_callback.call_count == 0 assert async_integration_callback.call_args[0][1] == ssdp.SsdpChange.ALIVE - mock_call_data: ssdp.SsdpServiceInfo = async_integration_callback.call_args[0][0] + mock_call_data: SsdpServiceInfo = async_integration_callback.call_args[0][0] assert mock_call_data.ssdp_ext == "" assert mock_call_data.ssdp_location == "http://1.1.1.1" assert mock_call_data.ssdp_server == "mock-server" @@ -568,8 +588,8 @@ async def test_scan_with_registered_callback( assert mock_call_data.ssdp_headers["_timestamp"] == ANY assert mock_call_data.x_homeassistant_matching_domains == set() assert mock_call_data.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert "Exception in SSDP callback" in caplog.text @@ -627,8 +647,8 @@ async def test_getting_existing_headers( assert discovery_info_by_st.ssdp_udn == ANY assert discovery_info_by_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn = await ssdp.async_get_discovery_info_by_udn( @@ -646,8 +666,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn.ssdp_udn == ANY assert discovery_info_by_udn.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } discovery_info_by_udn_st = await ssdp.async_get_discovery_info_by_udn_st( @@ -664,8 +684,8 @@ async def test_getting_existing_headers( assert discovery_info_by_udn_st.ssdp_udn == ANY assert discovery_info_by_udn_st.ssdp_headers["_timestamp"] == ANY assert discovery_info_by_udn_st.upnp == { - ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", - ssdp.ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", + ATTR_UPNP_DEVICE_TYPE: "Paulus", + ATTR_UPNP_UDN: "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL", } assert ( @@ -713,7 +733,7 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -738,7 +758,7 @@ async def test_async_detect_interfaces_setting_empty_route( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -787,7 +807,7 @@ async def test_bind_failure_skips_adapter( return_value={ "mock-domain": [ { - ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + ATTR_UPNP_DEVICE_TYPE: "ABC", } ] }, @@ -999,7 +1019,7 @@ async def test_ssdp_rediscover( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1086,7 +1106,7 @@ async def test_ssdp_rediscover_no_match( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + mock_call_data: SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" assert mock_call_data.ssdp_location == "http://1.1.1.1" @@ -1094,3 +1114,105 @@ async def test_ssdp_rediscover_no_match( await hass.async_block_till_done() assert len(mock_flow_init.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "SsdpServiceInfo", + "homeassistant.helpers.service_info.ssdp.SsdpServiceInfo", + SsdpServiceInfo, + ), + ( + "ATTR_ST", + "homeassistant.helpers.service_info.ssdp.ATTR_ST", + ATTR_ST, + ), + ( + "ATTR_NT", + "homeassistant.helpers.service_info.ssdp.ATTR_NT", + ATTR_NT, + ), + ( + "ATTR_UPNP_DEVICE_TYPE", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_DEVICE_TYPE", + ATTR_UPNP_DEVICE_TYPE, + ), + ( + "ATTR_UPNP_FRIENDLY_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_FRIENDLY_NAME", + ATTR_UPNP_FRIENDLY_NAME, + ), + ( + "ATTR_UPNP_MANUFACTURER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER", + ATTR_UPNP_MANUFACTURER, + ), + ( + "ATTR_UPNP_MANUFACTURER_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MANUFACTURER_URL", + ATTR_UPNP_MANUFACTURER_URL, + ), + ( + "ATTR_UPNP_MODEL_DESCRIPTION", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_DESCRIPTION", + ATTR_UPNP_MODEL_DESCRIPTION, + ), + ( + "ATTR_UPNP_MODEL_NAME", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NAME", + ATTR_UPNP_MODEL_NAME, + ), + ( + "ATTR_UPNP_MODEL_NUMBER", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_NUMBER", + ATTR_UPNP_MODEL_NUMBER, + ), + ( + "ATTR_UPNP_MODEL_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_MODEL_URL", + ATTR_UPNP_MODEL_URL, + ), + ( + "ATTR_UPNP_SERIAL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERIAL", + ATTR_UPNP_SERIAL, + ), + ( + "ATTR_UPNP_SERVICE_LIST", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_SERVICE_LIST", + ATTR_UPNP_SERVICE_LIST, + ), + ( + "ATTR_UPNP_UDN", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UDN", + ATTR_UPNP_UDN, + ), + ( + "ATTR_UPNP_UPC", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_UPC", + ATTR_UPNP_UPC, + ), + ( + "ATTR_UPNP_PRESENTATION_URL", + "homeassistant.helpers.service_info.ssdp.ATTR_UPNP_PRESENTATION_URL", + ATTR_UPNP_PRESENTATION_URL, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + ssdp, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 7e04c21562a..f15a80771cf 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -33,8 +33,9 @@ async def test_successful_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.runtime_data + assert entry.runtime_data.data assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] async def test_unload_entry(hass: HomeAssistant) -> None: @@ -59,4 +60,3 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/components/steamist/test_config_flow.py b/tests/components/steamist/test_config_flow.py index 40578113bb3..5e963f77a2b 100644 --- a/tests/components/steamist/test_config_flow.py +++ b/tests/components/steamist/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.steamist.const import DOMAIN from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( DEFAULT_ENTRY_DATA, @@ -30,7 +30,7 @@ from tests.common import MockConfigEntry MODULE = "homeassistant.components.steamist" -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), @@ -238,7 +238,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=DEVICE_IP_ADDRESS, macaddress="000000000000", diff --git a/tests/components/steamist/test_switch.py b/tests/components/steamist/test_switch.py index a20bebc4052..cd62c18590a 100644 --- a/tests/components/steamist/test_switch.py +++ b/tests/components/steamist/test_switch.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( MOCK_ASYNC_GET_STATUS_ACTIVE, diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 3f7303e97f6..40582dc4be3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -70,10 +70,10 @@ def mock_stookwijzer() -> Generator[MagicMock]: new=stookwijzer_mock, ), ): - stookwijzer_mock.async_transform_coordinates.return_value = ( - 200000.123456789, - 450000.123456789, - ) + stookwijzer_mock.async_transform_coordinates.return_value = { + "x": 450000.123456789, + "y": 200000.123456789, + } client = stookwijzer_mock.return_value client.lki = 2 diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr index f6751a84f22..ff1f6a12b8a 100644 --- a/tests/components/stookwijzer/snapshots/test_sensor.ambr +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -118,6 +120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 6dddf83c27a..060d2bdc26c 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -32,8 +32,8 @@ async def test_full_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stookwijzer" assert result["data"] == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,7 +47,7 @@ async def test_connection_error( ) -> None: """Test user configuration flow while connection fails.""" original_return_value = mock_stookwijzer.async_transform_coordinates.return_value - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index 0df9b55d1a9..4306b9afc26 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -66,8 +66,8 @@ async def test_migrate_entry( assert mock_v1_config_entry.version == 2 assert mock_v1_config_entry.data == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } @@ -81,7 +81,7 @@ async def test_entry_migration_failure( assert mock_v1_config_entry.version == 1 # Failed getting the transformed coordinates - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None mock_v1_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) @@ -100,6 +100,7 @@ async def test_entity_entry_migration( entity_registry: er.EntityRegistry, ) -> None: """Test successful migration of entry data.""" + mock_config_entry.add_to_hass(hass) entity = entity_registry.async_get_or_create( suggested_object_id="advice", disabled_by=None, diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 9ce297c3fb6..c96b7d9427f 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -19,7 +19,7 @@ from homeassistant.components.stream.const import ( from homeassistant.components.stream.core import Orientation, Part from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( FAKE_TIME, @@ -119,13 +119,16 @@ def make_playlist( response.extend( [ f"#EXT-X-PART-INF:PART-TARGET={part_target_duration:.3f}", - f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*part_target_duration:.3f}", - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*part_target_duration:.3f},PRECISE=YES", + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=" + f"{2 * part_target_duration:.3f}", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_LL_HLS * part_target_duration:.3f},PRECISE=YES", ] ) else: response.append( - f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*segment_duration:.3f},PRECISE=YES", + "#EXT-X-START:TIME-OFFSET=-" + f"{EXT_X_START_NON_LL_HLS * segment_duration:.3f},PRECISE=YES", ) if segments: response.extend(segments) diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 5577076830b..443103fdf92 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -99,18 +99,17 @@ def make_segment_with_parts( if discontinuity: response.append("#EXT-X-DISCONTINUITY") response.extend( - f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' + f"#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f}," + f'URI="./segment/{segment}.{i}.m4s"' + f"{',INDEPENDENT=YES' if i % independent_period == 0 else ''}" for i in range(num_parts) ) - response.extend( - [ - "#EXT-X-PROGRAM-DATE-TIME:" - + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - + "Z", - f"#EXTINF:{math.ceil(SEGMENT_DURATION/TEST_PART_DURATION)*TEST_PART_DURATION:.3f},", - f"./segment/{segment}.m4s", - ] + response.append( + f"#EXT-X-PROGRAM-DATE-TIME:{FAKE_TIME.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" ) + duration = math.ceil(SEGMENT_DURATION / TEST_PART_DURATION) * TEST_PART_DURATION + response.append(f"#EXTINF:{duration:.3f},") + response.append(f"./segment/{segment}.m4s") return "\n".join(response) @@ -166,7 +165,7 @@ async def test_ll_hls_stream( # Fetch playlist playlist_url = "/" + master_playlist.splitlines()[-1] playlist_response = await hls_client.get( - playlist_url + f"?_HLS_msn={num_playlist_segments-1}" + playlist_url + f"?_HLS_msn={num_playlist_segments - 1}" ) assert playlist_response.status == HTTPStatus.OK @@ -465,7 +464,8 @@ async def test_ll_hls_playlist_bad_msn_part( ).status == HTTPStatus.BAD_REQUEST assert ( await hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={num_completed_parts-1+hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" + "/playlist.m3u8?_HLS_msn=1&_HLS_part=" + f"{num_completed_parts - 1 + hass.data[DOMAIN][ATTR_SETTINGS].hls_advance_part_limit}" ) ).status == HTTPStatus.BAD_REQUEST stream_worker_sync.resume() @@ -515,13 +515,13 @@ async def test_ll_hls_playlist_rollover_part( *( [ hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)-1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) - 1}" ), hls_client.get( f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)}" ), hls_client.get( - f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts)+1}" + f"/playlist.m3u8?_HLS_msn=1&_HLS_part={len(segment.parts) + 1}" ), hls_client.get("/playlist.m3u8?_HLS_msn=2&_HLS_part=0"), ] diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 8e079cded45..7c856180f77 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -21,7 +21,7 @@ from homeassistant.components.stream.fmp4utils import find_box from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DefaultSegment as Segment, diff --git a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr index c74df76e71b..d13a19bc656 100644 --- a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr index d54cdcafb93..c1248f2c0a0 100644 --- a/tests/components/streamlabswater/snapshots/test_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 3d5daab2bec..cada4b0c533 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.stt import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from .common import ( @@ -34,7 +34,6 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, - reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -145,7 +144,7 @@ async def mock_config_entry_setup( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) @@ -519,9 +518,6 @@ async def test_default_engine_prefer_cloud_entity( assert provider_engine.name == "test" assert async_default_engine(hass) == "stt.cloud_stt_entity" - # Reset the `cloud` translations cache to avoid flaky translation checks - reset_translation_cache(hass, ["cloud"]) - async def test_get_engine_legacy( hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index e18ea8fd398..84d2fde97f7 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .api_responses import TEST_VIN_2_EV, VEHICLE_DATA, VEHICLE_STATUS_EV diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index 6abc544c92a..0b45546902b 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -136,6 +136,7 @@ async def test_user_form_pin_not_required( "data": deepcopy(TEST_CONFIG), "options": {}, "minor_version": 1, + "subentries": (), } expected["data"][CONF_PIN] = None @@ -341,6 +342,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "data": TEST_CONFIG, "options": {}, "minor_version": 1, + "subentries": (), } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index da0ed3df7dd..536e79df606 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index a30076d6d3c..3896498bbb0 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.sun import entity from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index cb97ae565c7..59e4e4c700b 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -10,9 +10,9 @@ import pytest from homeassistant.components import sun from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 303ca3b80cd..a7aeae25ac7 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index dbd689fc8f6..5ba65b2bd70 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,55 +196,6 @@ 'state': '2024-01-06T17:05:00+00:00', }) # --- -# name: test_all_entities[sensor.zurich_bern_duration-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.zurich_bern_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'swiss_public_transport', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Zürich Bern_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.zurich_bern_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by transport.opendata.ch', - 'device_class': 'duration', - 'friendly_name': 'Zürich Bern Duration', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zurich_bern_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }) -# --- # name: test_all_entities[sensor.zurich_bern_line-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -248,6 +203,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -295,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +299,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,3 +340,56 @@ 'state': '0', }) # --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trip_duration', + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Trip duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 4afdd88c9de..6e832728277 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -83,7 +83,7 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 7c4f434b0a4..5c5737804e1 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 08e6ab6d0f6..81f8a93611d 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 9ecffd395a3..4d6794b962f 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -274,3 +274,23 @@ LEAK_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + +REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Any"), + time=0, + connectable=False, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 3caa2a1f0df..1038bd318f5 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.switchbot.const import ( CONF_LOCK_NIGHTLATCH, CONF_RETRY_COUNT, ) -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import ( CONF_ADDRESS, CONF_NAME, @@ -303,6 +303,39 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None assert result["reason"] == "no_devices_found" +async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None: + """Test setting up a switchbot replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.config_flow.async_discovered_service_info", + return_value=[WOHAND_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Bot EEFF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_SENSOR_TYPE: "bot", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_setup_wocurtain(hass: HomeAssistant) -> None: """Test the user initiated form with password and valid mac.""" diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index acf1bacc054..6a7111a054e 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.setup import async_setup_component from . import ( LEAK_SERVICE_INFO, + REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, @@ -194,3 +195,42 @@ async def test_leak_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remote(hass: HomeAssistant) -> None: + """Test setting up the remote sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, REMOTE_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "remote", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "86" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index ce570499b3a..42fe3e4f543 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -def configure_integration(hass: HomeAssistant) -> MockConfigEntry: +async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: """Configure the integration.""" config = { CONF_API_TOKEN: "test-token", @@ -17,5 +17,7 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" ) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/switchbot_cloud/fixtures/meter_status.json b/tests/components/switchbot_cloud/fixtures/meter_status.json new file mode 100644 index 00000000000..8b5bcd0c031 --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/meter_status.json @@ -0,0 +1,9 @@ +{ + "version": "V3.3", + "temperature": 21.8, + "battery": 100, + "humidity": 32, + "deviceId": "meter-id-1", + "deviceType": "Meter", + "hubDeviceId": "test-hub-id" +} diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2446add959b --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -0,0 +1,313 @@ +# serializer version: 1 +# name: test_meter[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_meter[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.8', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py new file mode 100644 index 00000000000..0779e54ee03 --- /dev/null +++ b/tests/components/switchbot_cloud/test_button.py @@ -0,0 +1,65 @@ +"""Test for the switchbot_cloud bot as a button.""" + +from unittest.mock import patch + +from switchbot_api import BotCommands, Device + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_pressmode_bot( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test press.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "pressMode"} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "button.bot_1" + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "bot-id-1", BotCommands.PRESS, "command", "default" + ) + + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + +async def test_switchmode_bot_no_button_entity( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test a switchMode bot isn't added as a button.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "switchMode"} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids(BUTTON_DOMAIN) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 43431ae04c0..f4837c4e97e 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -64,9 +64,7 @@ async def test_setup_entry_success( ), ] mock_get_status.return_value = {"power": PowerState.ON.value} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -91,8 +89,7 @@ async def test_setup_entry_fails_when_listing_devices( ) -> None: """Test error handling when list_devices in setup of entry.""" mock_list_devices.side_effect = error - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state == state hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -114,9 +111,8 @@ async def test_setup_entry_fails_when_refreshing( ) ] mock_get_status.side_effect = CannotConnect - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index a09d7241794..fcb81abfc51 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -26,9 +26,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> mock_get_status.return_value = {"lockState": "locked"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py new file mode 100644 index 00000000000..6b0a52800f3 --- /dev/null +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -0,0 +1,65 @@ +"""Test for the switchbot_cloud sensors.""" + +from unittest.mock import patch + +from switchbot_api import Device +from syrupy import SnapshotAssertion + +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import configure_integration + +from tests.common import load_json_object_fixture, snapshot_platform + + +async def test_meter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test Meter sensors.""" + + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = load_json_object_fixture("meter_status.json", DOMAIN) + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_meter_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test meter sensors are unknown without coordinator data.""" + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index d4ef2c84549..99e0f50aa53 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -1,4 +1,4 @@ -"""Test for the switchbot_cloud relay switch.""" +"""Test for the switchbot_cloud relay switch & bot.""" from unittest.mock import patch @@ -34,10 +34,7 @@ async def test_relay_switch( mock_get_status.return_value = {"switchStatus": 0} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.relay_switch_1" @@ -54,3 +51,57 @@ async def test_relay_switch( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_switchmode_bot( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turn on and turn off.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.bot_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_pressmode_bot_no_switch_entity( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test a pressMode bot isn't added as a switch.""" + mock_list_devices.return_value = [ + Device( + deviceId="bot-id-1", + deviceName="bot-1", + deviceType="Bot", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"deviceMode": "pressMode"} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + assert not hass.states.async_entity_ids(SWITCH_DOMAIN) diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index defe970c674..57454e38062 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -91,8 +91,8 @@ DUMMY_POSITION = [54] DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] -DUMMY_CHILD_LOCK = [ShutterChildLock.OFF] -DUMMY_CHILD_LOCK_2 = [ShutterChildLock.OFF, ShutterChildLock.OFF] +DUMMY_CHILD_LOCK = [ShutterChildLock.ON] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.ON, ShutterChildLock.ON] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 53572085f9b..f59958420c4 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -69,5 +69,6 @@ async def test_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, } diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 9bfe11fe202..c20149de074 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -20,7 +20,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3, + DUMMY_PLUG_DEVICE, + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) + +ENTITY_ID = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" +ENTITY_ID2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE2.name)}_child_lock" +ENTITY_ID3 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_1" +ENTITY_ID3_2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_2" @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) @@ -137,3 +150,192 @@ async def test_switch_control_fail( mock_control_device.assert_called_once_with(Command.ON) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_switch( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test the switch.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on child lock + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test switch control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index b79e63e1ce7..727b95563cc 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -6,12 +6,19 @@ from unittest.mock import patch from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_PRESENTATION_URL, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -138,16 +145,16 @@ async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.2:5200/Printer.xml", upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", - ssdp.ATTR_UPNP_MANUFACTURER: "Samsung Electronics", - ssdp.ATTR_UPNP_PRESENTATION_URL: url, - ssdp.ATTR_UPNP_SERIAL: "00000000", - ssdp.ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:Printer:1", + ATTR_UPNP_MANUFACTURER: "Samsung Electronics", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_SERIAL: "00000000", + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", }, ), ) diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py new file mode 100644 index 00000000000..e98b0d21d66 --- /dev/null +++ b/tests/components/synology_dsm/common.py @@ -0,0 +1,22 @@ +"""Configure Synology DSM tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +from awesomeversion import AwesomeVersion + +from .consts import SERIAL + + +def mock_dsm_information( + serial: str | None = SERIAL, + update_result: bool = True, + awesome_version: str = "7.2", +) -> Mock: + """Mock SynologyDSM information.""" + return Mock( + serial=serial, + update=AsyncMock(return_value=update_result), + awesome_version=AwesomeVersion(awesome_version), + ) diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 0e8f79ffd40..96d6453cf16 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -8,6 +8,8 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import mock_dsm_information + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -31,8 +33,9 @@ def fixture_dsm(): dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) return dsm diff --git a/tests/components/synology_dsm/snapshots/test_config_flow.ambr b/tests/components/synology_dsm/snapshots/test_config_flow.ambr index 807ec764e52..384f6b885d7 100644 --- a/tests/components/synology_dsm/snapshots/test_config_flow.ambr +++ b/tests/components/synology_dsm/snapshots/test_config_flow.ambr @@ -84,3 +84,17 @@ 'verify_ssl': False, }) # --- +# name: test_user_with_filestation + dict({ + 'host': 'nas.meontheinternet.com', + 'mac': list([ + '00-11-32-XX-XX-59', + '00-11-32-XX-XX-5A', + ]), + 'password': 'password', + 'port': 1234, + 'ssl': True, + 'username': 'Home_Assistant', + 'verify_ssl': False, + }) +# --- diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py new file mode 100644 index 00000000000..24cfe29f52b --- /dev/null +++ b/tests/components/synology_dsm/test_backup.py @@ -0,0 +1,756 @@ +"""Tests for the Synology DSM backup agent.""" + +from io import StringIO +from typing import Any +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_2025-01-09_20.14_35457323" + + +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + +async def _mock_download_file(path: str, filename: str) -> MockStreamReader: + if filename == f"{BASE_FILENAME}_meta.json": + return MockStreamReader( + b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' + b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' + b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' + b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' + ) + if filename == f"{BASE_FILENAME}.tar": + return MockStreamReaderChunked(b"backup data") + raise MockStreamReaderChunked(b"") + + +async def _mock_download_file_meta_ok_tar_missing( + path: str, filename: str +) -> MockStreamReader: + if filename == f"{BASE_FILENAME}_meta.json": + return MockStreamReader( + b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' + b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' + b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' + b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' + ) + if filename == f"{BASE_FILENAME}.tar": + raise SynologyDSMAPIErrorException("api", "900", [{"code": 408}]) + raise MockStreamReaderChunked(b"") + + +async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader: + if filename == f"{BASE_FILENAME}_meta.json": + return MockStreamReader(b"im not a json") + if filename == f"{BASE_FILENAME}.tar": + return MockStreamReaderChunked(b"backup data") + raise MockStreamReaderChunked(b"") + + +@pytest.fixture +def mock_dsm_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = mock_dsm_information() + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ), + get_files=AsyncMock( + return_value=[ + SynoFileFile( + additional=None, + is_dir=False, + name=f"{BASE_FILENAME}_meta.json", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}_meta.json", + ), + SynoFileFile( + additional=None, + is_dir=False, + name=f"{BASE_FILENAME}.tar", + path=f"/ha_backup/my_backup_path/{BASE_FILENAME}.tar", + ), + ] + ), + download_file=_mock_download_file, + upload_file=AsyncMock(return_value=True), + delete_file=AsyncMock(return_value=True), + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +def mock_dsm_without_filestation(): + """Mock a successful service with filestation support.""" + + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.information = mock_dsm_information() + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.file = None + + yield dsm + + +@pytest.fixture +async def setup_dsm_with_filestation( + hass: HomeAssistant, + mock_dsm_with_filestation: MagicMock, +): + """Mock setup of synology dsm config entry and backup integration.""" + async_initialize_backup(hass) + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + options={ + CONF_BACKUP_PATH: "my_backup_path", + CONF_BACKUP_SHARE: "/ha_backup", + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + yield mock_dsm_with_filestation + + +async def test_agents_info( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, + ], + } + + +async def test_agents_not_loaded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent with no loaded config entry.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + ], + } + + +async def test_agents_on_unload( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent on un-loading config entry.""" + # config entry is loaded + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, + ], + } + + # unload config entry + entries = hass.config_entries.async_loaded_entries(DOMAIN) + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, + "backup_id": "abcd12ef", + "date": "2025-01-09T20:14:35.457323+01:00", + "database_included": True, + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.0.dev0", + "name": "Automatic backup 2025.2.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_list_backups_error( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent error while list backups.""" + client = await hass_ws_client(hass) + + setup_dsm_with_filestation.file.get_files.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + +async def test_agents_list_backups_disabled_filestation( + hass: HomeAssistant, + mock_dsm_without_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent error while list backups when file station is disabled.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert not response["success"] + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + ( + "abcd12ef", + { + "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, + "backup_id": "abcd12ef", + "date": "2025-01-09T20:14:35.457323+01:00", + "database_included": True, + "extra_metadata": {"instance_id": ANY, "with_automatic_settings": True}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.0.dev0", + "name": "Automatic backup 2025.2.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + }, + ), + ( + "12345", + None, + ), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_get_backup_not_existing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent get not existing backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = AsyncMock( + side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + ) + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}, "backup": None} + + +async def test_agents_get_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while get backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.get_files.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, + "backup": None, + } + + +async def test_agents_get_backup_defect_meta( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while get backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = _mock_download_file_meta_defect + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}, "backup": None} + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "abcd12ef" + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_download_not_existing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent download not existing backup.""" + client = await hass_client() + backup_id = "abcd12ef" + + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" + ) + assert resp.reason == "Internal Server Error" + assert resp.status == 500 + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + base_filename = "Test_1970-01-01_00.00_00000000" + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 2 + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}_meta.json" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while uploading backup.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + base_filename = "Test_1970-01-01_00.00_00000000" + + # fail to upload the tar file + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + setup_dsm_with_filestation.file.upload_file.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + assert "Failed to upload backup" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 1 + assert mock.call_args_list[0].kwargs["filename"] == f"{base_filename}.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + + # fail to upload the meta json file + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + setup_dsm_with_filestation.file.upload_file.side_effect = [ + True, + SynologyDSMAPIErrorException("api", "500", "error"), + ] + + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + assert "Failed to upload backup" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 3 + assert mock.call_args_list[1].kwargs["filename"] == f"{base_filename}.tar" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[2].kwargs["filename"] == f"{base_filename}_meta.json" + assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abcd12ef" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock: AsyncMock = setup_dsm_with_filestation.file.delete_file + assert len(mock.mock_calls) == 2 + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[1].kwargs["filename"] == f"{BASE_FILENAME}_meta.json" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_delete_not_existing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test delete not existing backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) + setup_dsm_with_filestation.file.delete_file = AsyncMock( + side_effect=SynologyDSMAPIErrorException( + "api", + "900", + [{"code": 408, "path": f"/ha_backup/my_backup_path/{backup_id}.tar"}], + ) + ) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +@pytest.mark.parametrize( + ("error", "expected_log"), + [ + ( + SynologyDSMAPIErrorException("api", "100", "Unknown error"), + "{'api': 'api', 'code': '100', 'reason': 'Unknown', 'details': 'Unknown error'}", + ), + ( + SynologyDSMAPIErrorException("api", "900", [{"code": 407}]), + "{'api': 'api', 'code': '900', 'reason': 'Unknown', 'details': [{'code': 407}]", + ), + ( + SynologyDSMAPIErrorException("api", "900", [{"code": 417}]), + "{'api': 'api', 'code': '900', 'reason': 'Unknown', 'details': [{'code': 417}]", + ), + ], +) +async def test_agents_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, + error: SynologyDSMAPIErrorException, + expected_log: str, +) -> None: + """Test error while delete backup.""" + client = await hass_ws_client(hass) + + # error while delete + backup_id = "abcd12ef" + setup_dsm_with_filestation.file.delete_file.side_effect = error + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete backup" + } + } + assert f"Failed to delete backup: {expected_log}" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.delete_file + assert len(mock.mock_calls) == 1 + assert mock.call_args_list[0].kwargs["filename"] == f"{BASE_FILENAME}.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index e5494b7179f..932cf057d3d 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -4,6 +4,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from synology_dsm.api.file_station.models import SynoFileSharedFolder from synology_dsm.exceptions import ( SynologyDSMException, SynologyDSMLogin2SAFailedException, @@ -13,12 +14,11 @@ from synology_dsm.exceptions import ( ) from syrupy import SnapshotAssertion -from homeassistant.components import ssdp, zeroconf from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, CONF_SNAPSHOT_QUALITY, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SNAPSHOT_QUALITY, DOMAIN, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, SOURCE_ZEROCONF @@ -27,14 +27,20 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .common import mock_dsm_information from .consts import ( DEVICE_TOKEN, HOST, @@ -67,8 +73,8 @@ def mock_controller_service(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) - + dsm.information = mock_dsm_information() + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -90,7 +96,8 @@ def mock_controller_service_2sa(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -110,7 +117,40 @@ def mock_controller_service_vdsm(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) + yield dsm + + +@pytest.fixture(name="service_with_filestation") +def mock_controller_service_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.config_flow.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = mock_dsm_information() + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ) + ) yield dsm @@ -131,8 +171,8 @@ def mock_controller_service_failed(): volumes_ids=[], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=None) - + dsm.information = mock_dsm_information(serial=None) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -278,6 +318,55 @@ async def test_user_vdsm( assert result["data"] == snapshot +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_with_filestation( + hass: HomeAssistant, + service_with_filestation: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test user config.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=None + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + # test with all provided + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_VERIFY_SSL: VERIFY_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "backup_share" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_BACKUP_SHARE: "/ha_backup", CONF_BACKUP_PATH: "automatic_ha_backups"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == HOST + assert result["data"] == snapshot + + @pytest.mark.usefixtures("mock_setup_entry") async def test_reauth(hass: HomeAssistant, service: MagicMock) -> None: """Test reauthentication.""" @@ -418,13 +507,13 @@ async def test_form_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` }, ), ) @@ -465,13 +554,13 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -508,13 +597,13 @@ async def test_skip_reconfig_ssdp( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{new_host}:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -541,13 +630,13 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.1.5:5000", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` + ATTR_UPNP_FRIENDLY_NAME: "mydsm", + ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ), ) @@ -555,46 +644,52 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_setup_entry") -async def test_options_flow(hass: HomeAssistant, service: MagicMock) -> None: +async def test_options_flow( + hass: HomeAssistant, service_with_filestation: MagicMock +) -> None: """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_MAC: MACS, - }, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=service_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert config_entry.options == {} + assert config_entry.options == {CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None} result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - # Scan interval - # Default - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL - assert config_entry.options[CONF_SNAPSHOT_QUALITY] == DEFAULT_SNAPSHOT_QUALITY - - # Manual result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0}, + user_input={ + CONF_SNAPSHOT_QUALITY: 0, + CONF_BACKUP_PATH: "my_nackup_path", + CONF_BACKUP_SHARE: "/ha_backup", + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 + assert config_entry.options[CONF_BACKUP_PATH] == "my_nackup_path" + assert config_entry.options[CONF_BACKUP_SHARE] == "/ha_backup" @pytest.mark.usefixtures("mock_setup_entry") @@ -606,7 +701,7 @@ async def test_discovered_via_zeroconf( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, @@ -645,7 +740,7 @@ async def test_discovered_via_zeroconf_missing_mac( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.5"), ip_addresses=[ip_address("192.168.1.5")], port=5000, diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 13d568e6137..7fe58719aa4 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -4,14 +4,22 @@ from unittest.mock import MagicMock, patch from synology_dsm.exceptions import SynologyDSMLoginInvalidException -from homeassistant.components.synology_dsm.const import DOMAIN, SERVICES +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DEFAULT_VERIFY_SSL, + DOMAIN, + SERVICES, +) from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -78,3 +86,40 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: assert not await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() mock_async_step_reauth.assert_called_once() + + +async def test_config_entry_migrations( + hass: HomeAssistant, mock_dsm: MagicMock +) -> None: + """Test if reauthentication flow is triggered.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + options={CONF_SCAN_INTERVAL: 30}, + ) + entry.add_to_hass(hass) + + assert CONF_VERIFY_SSL not in entry.data + assert CONF_BACKUP_SHARE not in entry.options + assert CONF_BACKUP_PATH not in entry.options + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.data[CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert CONF_SCAN_INTERVAL not in entry.options + assert entry.options[CONF_BACKUP_SHARE] is None + assert entry.options[CONF_BACKUP_PATH] is None diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index 0c7ab6bc1cc..dd454f92137 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.util.aiohttp import MockRequest +from .common import mock_dsm_information from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -44,6 +45,7 @@ def dsm_with_photos() -> MagicMock: dsm = MagicMock() dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) @@ -62,6 +64,7 @@ def dsm_with_photos() -> MagicMock: dsm.photos.get_item_thumbnail_url = AsyncMock( return_value="http://my.thumbnail.url" ) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) return dsm diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py new file mode 100644 index 00000000000..a094928b837 --- /dev/null +++ b/tests/components/synology_dsm/test_repairs.py @@ -0,0 +1,322 @@ +"""Test repairs for synology dsm.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.file_station.models import SynoFileSharedFolder + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME + +from tests.common import ANY, MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture +def mock_dsm_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = mock_dsm_information() + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ), + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_filestation( + hass: HomeAssistant, + mock_dsm_with_filestation: MagicMock, +): + """Mock setup of synology dsm config entry.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + options={ + CONF_BACKUP_PATH: None, + CONF_BACKUP_SHARE: None, + }, + unique_id="my_serial", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert await async_setup_component(hass, REPAIRS_DOMAIN, {}) + await hass.async_block_till_done() + + yield mock_dsm_with_filestation + + +async def test_create_issue( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the issue is created.""" + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["breaks_in_ha_version"] is None + assert issue["domain"] == DOMAIN + assert issue["issue_id"] == "missing_backup_setup_my_serial" + assert issue["translation_key"] == "missing_backup_setup" + + +async def test_missing_backup_ignore( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test missing backup location setup issue is ignored by the user.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert not issue["ignored"] + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # seelct to ignore the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "ignore"} + ) + assert data["type"] == "abort" + assert data["reason"] == "ignored" + + # check issue is ignored + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["ignored"] + + +async def test_missing_backup_success( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the missing backup location setup repair flow is fully processed by the user.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.options == {"backup_path": None, "backup_share": None} + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert not issue["ignored"] + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # seelct to confirm the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "confirm"} + ) + assert data["step_id"] == "confirm" + assert data["type"] == "form" + + # fill out the form and submit + data = await process_repair_fix_flow( + client, + flow_id, + json={"backup_share": "/ha_backup", "backup_path": "backup_ha_dev"}, + ) + assert data["type"] == "create_entry" + assert entry.options == { + "backup_path": "backup_ha_dev", + "backup_share": "/ha_backup", + } + + +async def test_missing_backup_no_shares( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the missing backup location setup repair flow errors out.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # inject error + setup_dsm_with_filestation.file.get_shared_folders.return_value = [] + + # select to confirm the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "confirm"} + ) + assert data["type"] == "abort" + assert data["reason"] == "no_shares" + + +@pytest.mark.parametrize( + "ignore_missing_translations", + ["component.synology_dsm.issues.other_issue.title"], +) +async def test_other_fixable_issues( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing another issue.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + + issue = { + "breaks_in_ha_version": None, + "domain": DOMAIN, + "issue_id": "other_issue", + "is_fixable": True, + "severity": "error", + "translation_key": "other_issue", + } + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + is_fixable=issue["is_fixable"], + severity=issue["severity"], + translation_key=issue["translation_key"], + ) + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + results = msg["result"]["issues"] + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "synology_dsm", + "ignored": False, + "is_fixable": True, + "issue_domain": None, + "issue_id": "other_issue", + "learn_more_url": None, + "severity": "error", + "translation_key": "other_issue", + "translation_placeholders": None, + } in results + + data = await start_repair_fix_flow(client, DOMAIN, "other_issue") + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == "create_entry" + await hass.async_block_till_done() diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py index 0606ce8e258..89bd1b652ba 100644 --- a/tests/components/system_bridge/__init__.py +++ b/tests/components/system_bridge/__init__.py @@ -15,9 +15,9 @@ from systembridgemodels.fixtures.modules.processes import FIXTURE_PROCESSES from systembridgemodels.fixtures.modules.system import FIXTURE_SYSTEM from systembridgemodels.modules import Module, ModulesData -from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -44,7 +44,7 @@ FIXTURE_ZEROCONF_INPUT = { CONF_PORT: "9170", } -FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, @@ -62,7 +62,7 @@ FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( }, ) -FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( +FIXTURE_ZEROCONF_BAD = ZeroconfServiceInfo( ip_address=ip_address(FIXTURE_USER_INPUT[CONF_HOST]), ip_addresses=[ip_address(FIXTURE_USER_INPUT[CONF_HOST])], port=9170, diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 75d942fc601..afa508cc004 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -56,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, @@ -111,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, diff --git a/tests/components/tado/fixtures/devices.json b/tests/components/tado/fixtures/devices.json index 6d990082b96..a9313ae051b 100644 --- a/tests/components/tado/fixtures/devices.json +++ b/tests/components/tado/fixtures/devices.json @@ -15,5 +15,24 @@ "value": true }, "shortSerialNo": "WR1" + }, + { + "duties": ["ZONE_UI", "ZONE_DRIVER", "ZONE_LEADER"], + "currentFwVersion": "59.4", + "deviceType": "WR02", + "serialNo": "WR4", + "shortSerialNo": "WR4", + "commandTableUploadState": "FINISHED", + "connectionState": { + "value": true, + "timestamp": "2020-03-23T18:30:07.377Z" + }, + "accessPointWiFi": { + "ssid": "tado8480" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "childLockEnabled": false } ] diff --git a/tests/components/tado/fixtures/zones.json b/tests/components/tado/fixtures/zones.json index e1d2ec759ba..acc4612b393 100644 --- a/tests/components/tado/fixtures/zones.json +++ b/tests/components/tado/fixtures/zones.json @@ -27,7 +27,8 @@ }, "characteristics": { "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] - } + }, + "childLockEnabled": false } ], "dateCreated": "2019-11-28T15:58:48.968Z", diff --git a/tests/components/tado/snapshots/test_climate.ambr b/tests/components/tado/snapshots/test_climate.ambr new file mode 100644 index 00000000000..6ba35b6f6f2 --- /dev/null +++ b/tests/components/tado/snapshots/test_climate.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_aircon_set_hvac_mode[cool-COOL] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'COOL', + 'AUTO', + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[dry-DRY] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'DRY', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[fan_only-FAN] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + None, + None, + 'AIR_CONDITIONING', + 'ON', + 'FAN', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[heat-HEAT] + _Call( + tuple( + 3, + 'NEXT_TIME_BLOCK', + 24.76, + None, + 'AIR_CONDITIONING', + 'ON', + 'HEAT', + 'AUTO', + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- +# name: test_aircon_set_hvac_mode[off-OFF] + _Call( + tuple( + 3, + 'MANUAL', + None, + None, + 'AIR_CONDITIONING', + 'OFF', + ), + dict({ + }), + ) +# --- +# name: test_heater_set_temperature + _Call( + tuple( + 1, + 'NEXT_TIME_BLOCK', + 22.0, + None, + 'HEATING', + 'ON', + 'HEAT', + None, + None, + None, + None, + None, + ), + dict({ + }), + ) +# --- diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 5a43c728b6e..0699551c9c0 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -1,5 +1,19 @@ """The sensor tests for the tado platform.""" +from unittest.mock import patch + +from PyTado.interface.api.my_tado import TadoZone +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -121,3 +135,104 @@ async def test_smartac_with_fanlevel_vertical_and_horizontal_swing( # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) + + +async def test_heater_set_temperature( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the set temperature of the heater.""" + + await async_init_integration(hass) + + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_state, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_state", + return_value={"setting": {"temperature": {"celsius": 22.0}}}, + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.baseboard_heater", ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + + mock_set_state.assert_called_once() + snapshot.assert_match(mock_set_state.call_args) + + +@pytest.mark.parametrize( + ("hvac_mode", "set_hvac_mode"), + [ + (HVACMode.HEAT, "HEAT"), + (HVACMode.DRY, "DRY"), + (HVACMode.FAN_ONLY, "FAN"), + (HVACMode.COOL, "COOL"), + (HVACMode.OFF, "OFF"), + ], +) +async def test_aircon_set_hvac_mode( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hvac_mode: HVACMode, + set_hvac_mode: str, +) -> None: + """Test the set hvac mode of the air conditioning.""" + + await async_init_integration(hass) + + with ( + patch( + "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_state, + patch( + "homeassistant.components.tado.__init__.PyTado.interface.api.Tado.get_zone_state", + return_value=TadoZone( + zone_id=1, + current_temp=18.7, + connection=None, + current_temp_timestamp="2025-01-02T12:51:52.802Z", + current_humidity=45.1, + current_humidity_timestamp="2025-01-02T12:51:52.802Z", + is_away=False, + current_hvac_action="IDLE", + current_fan_speed=None, + current_fan_level=None, + current_hvac_mode=set_hvac_mode, + current_swing_mode="OFF", + current_vertical_swing_mode="OFF", + current_horizontal_swing_mode="OFF", + target_temp=16.0, + available=True, + power="ON", + link="ONLINE", + ac_power_timestamp=None, + heating_power_timestamp="2025-01-02T13:01:11.758Z", + ac_power=None, + heating_power=None, + heating_power_percentage=0.0, + tado_mode="HOME", + overlay_termination_type="MANUAL", + overlay_termination_timestamp=None, + default_overlay_termination_type="MANUAL", + default_overlay_termination_duration=None, + preparation=False, + open_window=False, + open_window_detected=False, + open_window_attr={}, + precision=0.1, + ), + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.air_conditioning", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + mock_set_state.assert_called_once() + snapshot.assert_match(mock_set_state.call_args) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 63b17dad13e..19acb0aecbd 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -9,7 +9,6 @@ import pytest import requests from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tado.config_flow import NoHomes from homeassistant.components.tado.const import ( CONF_FALLBACK, @@ -19,16 +18,20 @@ from homeassistant.components.tado.const import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from tests.common import MockConfigEntry -def _get_mock_tado_api(getMe=None) -> MagicMock: +def _get_mock_tado_api(get_me=None) -> MagicMock: mock_tado = MagicMock() - if isinstance(getMe, Exception): - type(mock_tado).getMe = MagicMock(side_effect=getMe) + if isinstance(get_me, Exception): + type(mock_tado).get_me = MagicMock(side_effect=get_me) else: - type(mock_tado).getMe = MagicMock(return_value=getMe) + type(mock_tado).get_me = MagicMock(return_value=get_me) return mock_tado @@ -61,7 +64,7 @@ async def test_form_exceptions( assert result["errors"] == {"base": error} # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -131,7 +134,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( @@ -166,7 +169,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -189,7 +194,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: response_mock = MagicMock() type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + mock_tado_api = _get_mock_tado_api( + get_me=requests.HTTPError(response=response_mock) + ) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -210,7 +217,7 @@ async def test_no_homes(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_tado_api = _get_mock_tado_api(getMe={"homes": []}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) with patch( "homeassistant.components.tado.config_flow.Tado", @@ -231,13 +238,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -258,13 +265,13 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, + properties={ATTR_PROPERTIES_ID: "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) @@ -314,7 +321,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( patch( "homeassistant.components.tado.config_flow.Tado", diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index bdd7977f858..da959c2124a 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -1,45 +1,94 @@ """Helper method tests.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch -from homeassistant.components.tado import TadoConnector +from PyTado.interface import Tado +import pytest + +from homeassistant.components.tado import TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, + DOMAIN, ) from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry -def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector: + +@pytest.fixture +def entry(request: pytest.FixtureRequest) -> MockConfigEntry: + """Fixture for ConfigEntry with optional fallback.""" + fallback = ( + request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT + ) + return MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Tado", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + options={ + "fallback": fallback, + }, + ) + + +@pytest.fixture +def tado() -> Tado: + """Fixture for Tado instance.""" + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay" + ) as mock_set_zone_overlay: + instance = MagicMock(spec=Tado) + instance.set_zone_overlay = mock_set_zone_overlay + yield instance + + +def dummy_tado_connector( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> TadoDataUpdateCoordinator: """Return dummy tado connector.""" - return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback) + return TadoDataUpdateCoordinator(hass, entry, tado) -async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True) +async def test_overlay_mode_duration_set( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when duration is set.""" - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) - overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) + overlay_mode = decide_overlay_mode(coordinator=tado, duration=3600, zone_id=1) # Must select TIMER overlay assert overlay_mode == CONST_OVERLAY_TIMER -async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True) +async def test_overlay_mode_next_time_block_fallback( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when duration is not set.""" - integration_fallback = CONST_OVERLAY_TADO_MODE - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) + overlay_mode = decide_overlay_mode(coordinator=tado, duration=None, zone_id=1) # Must fallback to integration wide setting - assert overlay_mode == integration_fallback + assert overlay_mode == CONST_OVERLAY_TADO_MODE -async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_DEFAULT], indirect=True) +async def test_overlay_mode_tado_default_fallback( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when tado default is selected.""" - integration_fallback = CONST_OVERLAY_TADO_DEFAULT zone_fallback = CONST_OVERLAY_MANUAL - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) class MockZoneData: def __init__(self) -> None: @@ -49,28 +98,40 @@ async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: zone_data = {"zone": {zone_id: MockZoneData()}} with patch.dict(tado.data, zone_data): - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id) + overlay_mode = decide_overlay_mode( + coordinator=tado, duration=None, zone_id=zone_id + ) # Must fallback to zone setting assert overlay_mode == zone_fallback -async def test_duration_enabled_without_tado_default(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_MANUAL], indirect=True) +async def test_duration_enabled_without_tado_default( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test duration decide method when overlay is timer and duration is set.""" overlay = CONST_OVERLAY_TIMER expected_duration = 600 - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_MANUAL) + tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado) duration = decide_duration( - tado=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 + coordinator=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 ) # Should return the same duration value assert duration == expected_duration -async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("entry", [CONST_OVERLAY_TIMER], indirect=True) +async def test_duration_enabled_with_tado_default( + hass: HomeAssistant, entry: ConfigEntry, tado: Tado +) -> None: """Test overlay method selection when ended up with timer overlay and None duration.""" zone_fallback = CONST_OVERLAY_TIMER expected_duration = 45000 - tado = dummy_tado_connector(hass=hass, fallback=zone_fallback) + tado = dummy_tado_connector( + hass=hass, + entry=entry, + tado=tado, + ) class MockZoneData: def __init__(self) -> None: @@ -81,7 +142,7 @@ async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: zone_data = {"zone": {zone_id: MockZoneData()}} with patch.dict(tado.data, zone_data): duration = decide_duration( - tado=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback + coordinator=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback ) # Must fallback to zone timer setting assert duration == expected_duration diff --git a/tests/components/tado/test_service.py b/tests/components/tado/test_service.py index f1d12d235cc..336bef55ea1 100644 --- a/tests/components/tado/test_service.py +++ b/tests/components/tado/test_service.py @@ -80,7 +80,7 @@ async def test_add_meter_readings_exception( blocking=True, ) - assert "Could not set meter reading" in str(exc) + assert "Error setting Tado meter reading: Error" in str(exc.value) async def test_add_meter_readings_invalid( diff --git a/tests/components/tado/test_switch.py b/tests/components/tado/test_switch.py new file mode 100644 index 00000000000..2112f3a1ac7 --- /dev/null +++ b/tests/components/tado/test_switch.py @@ -0,0 +1,47 @@ +"""The sensor tests for the tado platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +CHILD_LOCK_SWITCH_ENTITY = "switch.baseboard_heater_child_lock" + + +async def test_child_lock(hass: HomeAssistant) -> None: + """Test creation of child lock entity.""" + + await async_init_integration(hass) + state = hass.states.get(CHILD_LOCK_SWITCH_ENTITY) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("method", "expected"), [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)] +) +async def test_set_child_lock(hass: HomeAssistant, method, expected) -> None: + """Test enable child lock on switch.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_child_lock" + ) as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + method, + {ATTR_ENTITY_ID: CHILD_LOCK_SWITCH_ENTITY}, + blocking=True, + ) + + mock_set_state.assert_called_once() + assert mock_set_state.call_args[0][1] is expected diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index a76858ab98e..5bf87dbed33 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -188,3 +188,8 @@ async def async_init_integration( if not skip_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + # For a first refresh + await entry.runtime_data.coordinator.async_refresh() + await entry.runtime_data.mobile_coordinator.async_refresh() + await hass.async_block_till_done() diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 064b391c43a..d04f2e726b5 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -129,6 +132,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index 17b656ec5fd..7d3d10aa609 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index b69bd9e6410..1a26a6c98a7 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -21,6 +21,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -51,6 +52,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +134,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index 3e2e0577ad5..7b906ef1976 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -29,6 +29,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index ca6fbacf0fc..2e8a8e7a727 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -11,13 +11,13 @@ from gotailwind import ( import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import zeroconf -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -156,7 +156,7 @@ async def test_zeroconf_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -208,7 +208,7 @@ async def test_zeroconf_flow_abort_incompatible_properties( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -243,7 +243,7 @@ async def test_zeroconf_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, @@ -303,7 +303,7 @@ async def test_zeroconf_flow_not_discovered_again( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index 3180c7c0b1d..b5b33d7c246 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/tankerkoenig/test_coordinator.py b/tests/components/tankerkoenig/test_coordinator.py index 3ba0dc31c5f..ff2ceca7442 100644 --- a/tests/components/tankerkoenig/test_coordinator.py +++ b/tests/components/tankerkoenig/test_coordinator.py @@ -25,7 +25,7 @@ from homeassistant.const import ATTR_ID, CONF_SHOW_ON_MAP, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONFIG_DATA diff --git a/tests/components/tasmota/snapshots/test_sensor.ambr b/tests/components/tasmota/snapshots/test_sensor.ambr index be011e595b9..8a5a78cd366 100644 --- a/tests/components/tasmota/snapshots/test_sensor.ambr +++ b/tests/components/tasmota/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -149,6 +151,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -254,6 +257,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -401,6 +405,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -452,6 +457,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -503,6 +509,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -586,6 +593,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -632,6 +640,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -741,6 +750,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -824,6 +834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +886,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -990,6 +1002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1041,6 +1054,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1156,6 +1170,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1239,6 +1254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1290,6 +1306,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1405,6 +1422,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1552,6 +1570,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1603,6 +1622,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1654,6 +1674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1732,6 +1753,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1860,6 +1882,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1909,6 +1932,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1958,6 +1982,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 5abb9ab9bf2..ff951e058cb 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -13,6 +13,7 @@ from hatasmota.utils import ( ) import pytest +from homeassistant import core as ha from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -22,9 +23,8 @@ from homeassistant.const import ( STATE_UNKNOWN, Platform, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( DEFAULT_CONFIG, diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 4d2c821fff4..674ae316ecc 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -27,7 +27,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import async_fire_mqtt_message +from tests.common import MockMqttReasonCode, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockPahoClient, WebSocketGenerator DEFAULT_CONFIG = { @@ -165,7 +165,7 @@ async def help_test_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -174,7 +174,7 @@ async def help_test_availability_when_connection_lost( # Reconnected to MQTT server -> state still unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -226,7 +226,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -235,7 +235,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Reconnected to MQTT server -> state no longer unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -478,7 +478,7 @@ async def help_test_availability_poll_state( # Disconnected from MQTT server mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -486,7 +486,7 @@ async def help_test_availability_poll_state( # Reconnected to MQTT server mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 4f4daee1301..6a2b7699840 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1514,7 +1514,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1524,7 +1524,7 @@ async def _test_split_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Channel{idx+num_switches+1} {(idx+1)*10}", + f"NoDelay;Channel{idx + num_switches + 1} {(idx + 1) * 10}", 0, False, ) @@ -1595,7 +1595,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Power{idx+num_switches+1} ON", + f"NoDelay;Power{idx + num_switches + 1} ON", 0, False, ) @@ -1605,7 +1605,7 @@ async def _test_unlinked_light( await common.async_turn_on(hass, entity, brightness=(idx + 1) * 25.5) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", - f"NoDelay;Dimmer{idx+1} {(idx+1)*10}", + f"NoDelay;Dimmer{idx + 1} {(idx + 1) * 10}", 0, False, ) diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py index 27003df46cd..ade4b9f93d4 100644 --- a/tests/components/tcp/test_sensor.py +++ b/tests/components/tcp/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest -import homeassistant.components.tcp.common as tcp +from homeassistant.components.tcp import common as tcp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr index f08dd6970fe..5d9bcd2175a 100644 --- a/tests/components/technove/snapshots/test_binary_sensor.ambr +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_diagnostics.ambr b/tests/components/technove/snapshots/test_diagnostics.ambr index 175e8f2022a..e16c51a2e98 100644 --- a/tests/components/technove/snapshots/test_diagnostics.ambr +++ b/tests/components/technove/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ 'current': 23.75, 'energy_session': 12.34, 'energy_total': 1234, - 'high_charge_period_active': False, + 'high_tariff_period_active': False, 'in_sharing_mode': False, 'is_battery_protected': False, 'is_session_active': True, diff --git a/tests/components/technove/snapshots/test_number.ambr b/tests/components/technove/snapshots/test_number.ambr index 622c04d542a..eea4b0cb64c 100644 --- a/tests/components/technove/snapshots/test_number.ambr +++ b/tests/components/technove/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index 149155519d4..aaec5667e55 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -316,10 +322,11 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -356,7 +363,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'context': , @@ -376,6 +383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -425,6 +433,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_switch.ambr b/tests/components/technove/snapshots/test_switch.ambr index 6febc8c768c..a5f8411747b 100644 --- a/tests/components/technove/snapshots/test_switch.ambr +++ b/tests/components/technove/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py index 81e0b32b55b..99a8e231f73 100644 --- a/tests/components/technove/test_config_flow.py +++ b/tests/components/technove/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from technove import TechnoVEConnectionError -from homeassistant.components import zeroconf from homeassistant.components.technove.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -112,7 +112,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -153,7 +153,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -184,7 +184,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -225,7 +225,7 @@ async def test_zeroconf_without_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -250,7 +250,7 @@ async def test_zeroconf_with_mac_station_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index e3238dacda1..c2210a7ca5d 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,6 +339,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index af559f561b2..28b5ef7a7ed 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index cca988663d2..432c3ebd19f 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -85,6 +87,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -132,6 +135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr index 297fe9b0d37..22679c4153a 100644 --- a/tests/components/tedee/snapshots/test_sensor.ambr +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 93137c3815e..f15db7eba2b 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -105,6 +105,14 @@ def mock_external_calls() -> Generator[None]: patch.object(BotMock, "get_me", return_value=test_user), patch.object(BotMock, "bot", test_user), patch.object(BotMock, "send_message", return_value=message), + patch.object(BotMock, "send_photo", return_value=message), + patch.object(BotMock, "send_sticker", return_value=message), + patch.object(BotMock, "send_video", return_value=message), + patch.object(BotMock, "send_document", return_value=message), + patch.object(BotMock, "send_voice", return_value=message), + patch.object(BotMock, "send_animation", return_value=message), + patch.object(BotMock, "send_location", return_value=message), + patch.object(BotMock, "send_poll", return_value=message), patch("telegram.ext.Updater._bootstrap"), ): yield diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index bdf6ba72fcc..c9038003cfc 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,17 +1,33 @@ """Tests for the telegram_bot component.""" +import base64 +import io from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from telegram import Update from telegram.error import NetworkError, RetryAfter, TelegramError, TimedOut from homeassistant.components.telegram_bot import ( + ATTR_FILE, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_MESSAGE, ATTR_MESSAGE_THREAD_ID, + ATTR_OPTIONS, + ATTR_QUESTION, + ATTR_STICKER_ID, DOMAIN, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_LOCATION, SERVICE_SEND_MESSAGE, + SERVICE_SEND_PHOTO, + SERVICE_SEND_POLL, + SERVICE_SEND_STICKER, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.const import EVENT_HOMEASSISTANT_START @@ -32,23 +48,125 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True -async def test_send_message(hass: HomeAssistant, webhook_platform) -> None: - """Test the send_message service.""" +@pytest.mark.parametrize( + ("service", "input"), + [ + ( + SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + ), + ( + SERVICE_SEND_STICKER, + { + ATTR_STICKER_ID: "1", + ATTR_MESSAGE_THREAD_ID: "123", + }, + ), + ( + SERVICE_SEND_POLL, + { + ATTR_QUESTION: "Question", + ATTR_OPTIONS: ["Yes", "No"], + }, + ), + ( + SERVICE_SEND_LOCATION, + { + ATTR_MESSAGE: "test_message", + ATTR_MESSAGE_THREAD_ID: "123", + ATTR_LONGITUDE: "1.123", + ATTR_LATITUDE: "1.123", + }, + ), + ], +) +async def test_send_message( + hass: HomeAssistant, webhook_platform, service: str, input: dict[str] +) -> None: + """Test the send_message service. Tests any service that does not require files to be sent.""" context = Context() events = async_capture_events(hass, "telegram_sent") - await hass.services.async_call( + response = await hass.services.async_call( DOMAIN, - SERVICE_SEND_MESSAGE, - {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + service, + input, blocking=True, context=context, + return_response=True, ) await hass.async_block_till_done() assert len(events) == 1 assert events[0].context == context + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + + +@patch( + "builtins.open", + mock_open( + read_data=base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + ) + ), + create=True, +) +def _read_file_as_bytesio_mock(file_path): + """Convert file to BytesIO for testing.""" + _file = None + + with open(file_path, encoding="utf8") as file_handler: + _file = io.BytesIO(file_handler.read()) + + _file.name = "dummy" + _file.seek(0) + + return _file + + +@pytest.mark.parametrize( + "service", + [ + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + ], +) +async def test_send_file(hass: HomeAssistant, webhook_platform, service: str) -> None: + """Test the send_file service (photo, animation, video, document...).""" + context = Context() + events = async_capture_events(hass, "telegram_sent") + + hass.config.allowlist_external_dirs.add("/media/") + + # Mock the file handler read with our base64 encoded dummy file + with patch( + "homeassistant.components.telegram_bot._read_file_as_bytesio", + _read_file_as_bytesio_mock, + ): + response = await hass.services.async_call( + DOMAIN, + service, + { + ATTR_FILE: "/media/dummy", + ATTR_MESSAGE_THREAD_ID: "123", + }, + blocking=True, + context=context, + return_response=True, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context == context + + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> None: """Test the send_message service for threads.""" @@ -66,7 +184,7 @@ async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> Non assert len(events) == 1 assert events[0].context == context - assert events[0].data[ATTR_MESSAGE_THREAD_ID] == "123" + assert events[0].data[ATTR_MESSAGE_THREAD_ID] == 123 async def test_webhook_endpoint_generates_telegram_text_event( diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py index d1e74f1ab0f..445adc0b5bd 100644 --- a/tests/components/temper/test_sensor.py +++ b/tests/components/temper/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 3e3a629b4be..a7ee953bb09 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 1df9e738b06..dd008a27822 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -19,7 +19,7 @@ from homeassistant.components.template import DOMAIN, SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from tests.common import async_mock_service @@ -40,7 +40,7 @@ def patch_blueprint( return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), + yaml_util.load_yaml(data_path), expected_domain=self.domain, path=path, schema=BLUEPRINT_SCHEMA, @@ -149,6 +149,69 @@ async def test_inverted_binary_sensor( ) +async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> None: + """Test a template is updated at reload if the blueprint has changed.""" + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + config = { + DOMAIN: [ + { + "use_blueprint": { + "path": "inverted_binary_sensor.yaml", + "input": {"reference_entity": "binary_sensor.foo"}, + }, + "name": "Inverted foo", + }, + ] + } + with patch_blueprint( + "inverted_binary_sensor.yaml", + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml", + ): + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.foo").state == "off" + + inverted = hass.states.get("binary_sensor.inverted_foo") + assert inverted + assert inverted.state == "on" + + # Reload the automations without any change, but with updated blueprint + blueprint_config = yaml_util.load_yaml( + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml" + ) + blueprint_config["binary_sensor"]["state"] = "{{ states(reference_entity) }}" + with ( + patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ), + patch( + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", + autospec=True, + return_value=blueprint_config, + ), + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "off" + + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "on" + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b5ba93a4bd0..a94ec233f81 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1847,6 +1847,60 @@ async def test_supports_transition_template( ) != expected_value +@pytest.mark.parametrize("count", [1]) +async def test_supports_transition_template_updates( + hass: HomeAssistant, count: int +) -> None: + """Test the template for the supports transition dynamically.""" + light_config = { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": "{{ states('sensor.test') }}", + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state is not None + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + hass.states.async_set("sensor.test", 1) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert ( + supported_features == LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + ) + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "light_config", diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 929a890ab38..6f0e6be8a2a 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -24,11 +24,11 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -393,7 +393,7 @@ async def test_creating_sensor_loads_group(hass: HomeAssistant) -> None: async def async_setup_template( hass: HomeAssistant, config: ConfigType, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: order.append("sensor.template") diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index a131f5f606b..49b89b61d34 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 2396e2a88f3..06d2b54c936 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.tesla_fleet.const import DOMAIN, SCOPES from .const import ( COMMAND_OK, + ENERGY_HISTORY, LIVE_STATUS, PRODUCTS, SITE_INFO, @@ -177,6 +178,16 @@ def mock_request(): yield mock_request +@pytest.fixture(autouse=True) +def mock_energy_history(): + """Mock Teslemetry Energy Specific site_info method.""" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.energy_history", + return_value=ENERGY_HISTORY, + ) as mock_live_status: + yield mock_live_status + + @pytest.fixture(autouse=True) def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" diff --git a/tests/components/tesla_fleet/const.py b/tests/components/tesla_fleet/const.py index 76b4ae20092..d584e7b93d5 100644 --- a/tests/components/tesla_fleet/const.py +++ b/tests/components/tesla_fleet/const.py @@ -11,6 +11,7 @@ PRODUCTS = load_json_object_fixture("products.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) +ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} diff --git a/tests/components/tesla_fleet/fixtures/energy_history.json b/tests/components/tesla_fleet/fixtures/energy_history.json new file mode 100644 index 00000000000..befe12cc903 --- /dev/null +++ b/tests/components/tesla_fleet/fixtures/energy_history.json @@ -0,0 +1,45 @@ +{ + "response": { + "period": "day", + "time_series": [ + { + "timestamp": "2023-06-01T01:00:00-07:00", + "solar_energy_exported": 70940, + "generator_energy_exported": 0, + "grid_energy_imported": 521, + "grid_services_energy_imported": 17.53125, + "grid_services_energy_exported": 3.80859375, + "grid_energy_exported_from_solar": 43660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 19, + "battery_energy_exported": 10030, + "battery_energy_imported_from_grid": 80, + "battery_energy_imported_from_solar": 16800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 441, + "consumer_energy_imported_from_solar": 10480, + "consumer_energy_imported_from_battery": 10011, + "consumer_energy_imported_from_generator": 0 + }, + { + "timestamp": "2023-06-01T01:05:00-07:00", + "solar_energy_exported": 140940, + "generator_energy_exported": 1, + "grid_energy_imported": 1021, + "grid_services_energy_imported": 27.53125, + "grid_services_energy_exported": 6.80859375, + "grid_energy_exported_from_solar": 83660, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 29, + "battery_energy_exported": 20030, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 26800, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 841, + "consumer_energy_imported_from_solar": 20480, + "consumer_energy_imported_from_battery": 20011, + "consumer_energy_imported_from_generator": 0 + } + ] + } +} diff --git a/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr index 479d647e1c7..4e34f586280 100644 --- a/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -237,6 +242,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +290,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -331,6 +338,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +385,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -424,6 +433,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -471,6 +481,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +529,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -565,6 +577,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -612,6 +625,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +672,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -751,6 +767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -845,6 +863,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -892,6 +911,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -938,6 +958,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -985,6 +1006,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1054,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1079,6 +1102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1126,6 +1150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1173,6 +1198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1219,6 +1245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_button.ambr b/tests/components/tesla_fleet/snapshots/test_button.ambr index 8b5270d4852..145b10112b3 100644 --- a/tests/components/tesla_fleet/snapshots/test_button.ambr +++ b/tests/components/tesla_fleet/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_climate.ambr b/tests/components/tesla_fleet/snapshots/test_climate.ambr index 696f8c37f08..f3b36730c3f 100644 --- a/tests/components/tesla_fleet/snapshots/test_climate.ambr +++ b/tests/components/tesla_fleet/snapshots/test_climate.ambr @@ -15,6 +15,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +158,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -225,6 +228,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +300,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +370,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_cover.ambr b/tests/components/tesla_fleet/snapshots/test_cover.ambr index dbdb003d802..ed6969262f1 100644 --- a/tests/components/tesla_fleet/snapshots/test_cover.ambr +++ b/tests/components/tesla_fleet/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -486,6 +496,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -582,6 +594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +643,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -678,6 +692,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index 02ad4b01002..dc142c4ffeb 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index e9828db9f1b..c482d33de86 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tesla_fleet/snapshots/test_lock.ambr b/tests/components/tesla_fleet/snapshots/test_lock.ambr index 3384bb0eb97..e98ad09caad 100644 --- a/tests/components/tesla_fleet/snapshots/test_lock.ambr +++ b/tests/components/tesla_fleet/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr index cc3018364a5..77c46faedd7 100644 --- a/tests/components/tesla_fleet/snapshots/test_media_player.ambr +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index 00dd67015fe..1981544a024 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_select.ambr b/tests/components/tesla_fleet/snapshots/test_select.ambr index f29ce841113..171b52decf1 100644 --- a/tests/components/tesla_fleet/snapshots/test_select.ambr +++ b/tests/components/tesla_fleet/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +309,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +548,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index 2c3780749ca..f7349c9e2d8 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -1,4 +1,448 @@ # serializer version: 1 +# name: test_sensors[sensor.energy_site_battery_charged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_charged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_charge', + 'unique_id': '123456-total_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_discharged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery discharged', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_discharge', + 'unique_id': '123456-total_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_exported', + 'unique_id': '123456-battery_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.06', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_generator', + 'unique_id': '123456-battery_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_grid', + 'unique_id': '123456-battery_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.08', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_solar', + 'unique_id': '123456-battery_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43.6', + }) +# --- # name: test_sensors[sensor.energy_site_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8,6 +452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +517,302 @@ 'state': '5.06', }) # --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_battery', + 'unique_id': '123456-consumer_energy_imported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.022', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_generator', + 'unique_id': '123456-consumer_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_grid', + 'unique_id': '123456-consumer_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.282', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_solar', + 'unique_id': '123456-consumer_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.96', + }) +# --- # name: test_sensors[sensor.energy_site_energy_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -81,6 +822,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +887,80 @@ 'state': '38.8964736842105', }) # --- +# name: test_sensors[sensor.energy_site_generator_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_energy_exported', + 'unique_id': '123456-generator_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001', + }) +# --- # name: test_sensors[sensor.energy_site_generator_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -154,6 +970,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +1035,376 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_grid_energy_exported', + 'unique_id': '123456-total_grid_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from battery', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_battery', + 'unique_id': '123456-grid_energy_exported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.048', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.048', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from generator', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_generator', + 'unique_id': '123456-grid_energy_exported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from solar', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_solar', + 'unique_id': '123456-grid_energy_exported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.32', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_imported', + 'unique_id': '123456-grid_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.542', + }) +# --- # name: test_sensors[sensor.energy_site_grid_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -227,6 +1414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -291,6 +1479,154 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_services_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_exported', + 'unique_id': '123456-grid_services_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0106171875', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0106171875', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services imported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_imported', + 'unique_id': '123456-grid_services_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0450625', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0450625', + }) +# --- # name: test_sensors[sensor.energy_site_grid_services_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -300,6 +1636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +1716,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -447,6 +1785,80 @@ 'state': 'on_grid', }) # --- +# name: test_sensors[sensor.energy_site_home_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_home_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Home usage', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_home_usage', + 'unique_id': '123456-total_home_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -456,6 +1868,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +1942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -590,6 +2004,154 @@ 'state': '95.5053740373966', }) # --- +# name: test_sensors[sensor.energy_site_solar_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar exported', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_exported', + 'unique_id': '123456-solar_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '211.88', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar generated', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_solar_generation', + 'unique_id': '123456-total_solar_generation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_solar_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -599,6 +2161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +2235,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -743,6 +2307,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -802,6 +2367,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,6 +2433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -934,6 +2501,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1005,6 +2573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1066,6 +2635,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1136,6 +2706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1206,6 +2777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1273,6 +2845,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1340,6 +2913,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1414,6 +2988,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1493,6 +3068,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1563,6 +3139,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1633,6 +3210,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1704,6 +3282,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1765,6 +3344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1838,6 +3418,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1908,6 +3489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1981,6 +3563,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2051,6 +3634,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2121,6 +3705,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2193,6 +3778,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2268,6 +3854,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2338,6 +3925,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2403,6 +3991,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2464,6 +4053,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2527,6 +4117,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2600,6 +4191,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2673,6 +4265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2746,6 +4339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2819,6 +4413,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2886,6 +4481,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2951,6 +4547,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3010,6 +4607,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3071,6 +4669,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3144,6 +4743,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3215,6 +4815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3274,6 +4875,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3333,6 +4935,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3392,6 +4995,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr index 2d69a7d314a..2ea3bcc5ee5 100644 --- a/tests/components/tesla_fleet/snapshots/test_switch.ambr +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -262,7 +268,7 @@ 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +284,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,7 +464,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 7e97096e4e8..ff103ce03c2 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -21,6 +21,7 @@ from tesla_fleet_api.exceptions import ( from homeassistant.components.tesla_fleet.const import AUTHORIZE_URL from homeassistant.components.tesla_fleet.coordinator import ( + ENERGY_HISTORY_INTERVAL, ENERGY_INTERVAL, ENERGY_INTERVAL_SECONDS, VEHICLE_INTERVAL, @@ -155,14 +156,15 @@ async def test_vehicle_refresh_offline( mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() - # Then the vehicle goes offline + # Then the vehicle goes offline despite saying its online mock_vehicle_data.side_effect = VehicleOffline freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - mock_vehicle_state.assert_not_called() + mock_vehicle_state.assert_called_once() mock_vehicle_data.assert_called_once() + mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() # And stays offline @@ -211,20 +213,15 @@ async def test_vehicle_refresh_ratelimited( assert (state := hass.states.get("sensor.test_battery_level")) assert state.state == "unknown" - assert mock_vehicle_data.call_count == 1 + + mock_vehicle_data.reset_mock() freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # Should not call for another 10 seconds - assert mock_vehicle_data.call_count == 1 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "unknown" async def test_vehicle_sleep( @@ -317,6 +314,21 @@ async def test_energy_site_refresh_error( assert normal_config_entry.state is state +# Test Energy History Coordinator +@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +async def test_energy_history_refresh_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + side_effect: TeslaFleetError, + state: ConfigEntryState, +) -> None: + """Test coordinator refresh with an error.""" + mock_energy_history.side_effect = side_effect + await setup_platform(hass, normal_config_entry) + assert normal_config_entry.state is state + + async def test_energy_live_refresh_ratelimited( hass: HomeAssistant, normal_config_entry: MockConfigEntry, @@ -379,6 +391,39 @@ async def test_energy_info_refresh_ratelimited( assert mock_site_info.call_count == 3 +async def test_energy_history_refresh_ratelimited( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_energy_history: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator refresh handles 429.""" + + await setup_platform(hass, normal_config_entry) + + mock_energy_history.side_effect = RateLimited( + {"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)} + ) + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not call for another 10 seconds + assert mock_energy_history.call_count == 2 + + freezer.tick(ENERGY_HISTORY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_energy_history.call_count == 3 + + async def test_init_region_issue( hass: HomeAssistant, normal_config_entry: MockConfigEntry, diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index e10ae190a59..e4499d6e308 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.tesla_wall_connector.const import ( ) from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -124,9 +124,9 @@ async def _test_sensors( for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) assert state, f"Unable to get state of {entity.entity_id}" - assert ( - state.state == entity.first_value - ), f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + assert state.state == entity.first_value, ( + f"First update: {entity.entity_id} is expected to have state {entity.first_value} but has {state.state}" + ) # Simulate second data update with ( @@ -147,6 +147,6 @@ async def _test_sensors( # Verify expected vs actual values of second update for entity in entities_and_expected_values: state = hass.states.get(entity.entity_id) - assert ( - state.state == entity.second_value - ), f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + assert state.state == entity.second_value, ( + f"Second update: {entity.entity_id} is expected to have state {entity.second_value} but has {state.state}" + ) diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py index a0c28262658..fc1f4199515 100644 --- a/tests/components/tesla_wall_connector/test_config_flow.py +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -5,11 +5,11 @@ from unittest.mock import patch from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.tesla_wall_connector.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -113,7 +113,7 @@ async def test_dhcp_can_finish( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_abc", ip="1.2.3.4", macaddress="aadc44271212", @@ -146,7 +146,7 @@ async def test_dhcp_already_exists( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", @@ -170,7 +170,7 @@ async def test_dhcp_error_from_wall_connector( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="teslawallconnector_aabbcc", ip="1.2.3.4", macaddress="aabbccddeeff", diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index b6b9df7eb4b..59727926f03 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -14,7 +14,9 @@ from .const import CONFIG from tests.common import MockConfigEntry -async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): +async def setup_platform( + hass: HomeAssistant, platforms: list[Platform] | None = None +) -> MockConfigEntry: """Set up the Teslemetry platform.""" mock_entry = MockConfigEntry( @@ -32,6 +34,19 @@ async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = return mock_entry +async def reload_platform( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] | None = None +): + """Reload the Teslemetry platform.""" + + if platforms is None: + await hass.config_entries.async_reload(entry.entry_id) + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + def assert_entities( hass: HomeAssistant, entry_id: str, diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 256428aa703..e89bab9eff1 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -7,6 +7,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest +from teslemetry_stream.stream import recursive_match from .const import ( COMMAND_OK, @@ -48,6 +49,15 @@ def mock_vehicle_data() -> Generator[AsyncMock]: yield mock_vehicle_data +@pytest.fixture +def mock_legacy(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ) as mock_pre2021: + yield mock_pre2021 + + @pytest.fixture(autouse=True) def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" @@ -109,9 +119,53 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_listen(): +def mock_add_listener(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.listen", - ) as mock_listen: - yield mock_listen + "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + ) as mock_add_listener: + mock_add_listener.listeners = [] + + def unsubscribe() -> None: + return + + def side_effect(callback, filters): + mock_add_listener.listeners.append((callback, filters)) + return unsubscribe + + def send(event) -> None: + for listener, filters in mock_add_listener.listeners: + if recursive_match(filters, event): + listener(event) + + mock_add_listener.send = send + mock_add_listener.side_effect = side_effect + yield mock_add_listener + + +@pytest.fixture(autouse=True) +def mock_stream_get_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.get_config", + ) as mock_stream_get_config: + yield mock_stream_get_config + + +@pytest.fixture(autouse=True) +def mock_stream_update_config(): + """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStreamVehicle.update_config", + ) as mock_stream_update_config: + yield mock_stream_update_config + + +@pytest.fixture(autouse=True) +def mock_stream_connected(): + """Mock Teslemetry Stream listen method.""" + with patch( + "homeassistant.components.teslemetry.TeslemetryStream.connected", + return_value=True, + ) as mock_stream_connected: + yield mock_stream_connected diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 46efed2153d..40d55dab71f 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -18,6 +18,7 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +METADATA = load_json_object_fixture("metadata.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/config.json b/tests/components/teslemetry/fixtures/config.json new file mode 100644 index 00000000000..0a6d2b11ab0 --- /dev/null +++ b/tests/components/teslemetry/fixtures/config.json @@ -0,0 +1,10 @@ +{ + "exp": 1749261108, + "hostname": "na.teslemetry.com", + "port": 4431, + "prefer_typed": true, + "pending": false, + "fields": { + "ChargeAmps": { "interval_seconds": 60 } + } +} diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json new file mode 100644 index 00000000000..60282afc934 --- /dev/null +++ b/tests/components/teslemetry/fixtures/metadata.json @@ -0,0 +1,22 @@ +{ + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "energy_device_data", + "energy_cmds" + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "access": true, + "polling": true, + "proxy": true, + "firmware": "2024.44.25" + } + } +} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index fcfa0707b2c..0cd238c4e52 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 5ef5ea92a74..ec524614d49 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -35,7 +35,7 @@ "charge_port_cold_weather_mode": false, "charge_port_color": "", "charge_port_door_open": true, - "charge_port_latch": "Engaged", + "charge_port_latch": null, "charge_rate": 0, "charger_actual_current": 0, "charger_phases": null, @@ -190,7 +190,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2023.44.30.8 06f534d46010", + "car_version": "2024.44.25 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 95330840109..6a6e9826dc2 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +187,100 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic blind spot camera', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_blind_spot_camera', + 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic emergency braking off', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_emergency_braking_off', + 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -190,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,22 +326,164 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Blind spot collision warning chime', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'blind_spot_collision_warning_chime', + 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BMS full charge', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bms_full_charge_complete', + 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_brake_pedal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_brake_pedal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brake pedal', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brake_pedal', + 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -254,7 +495,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Cabin overheat protection actively cooling', + 'original_name': 'Cabin overheat protection active', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -263,14 +504,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -284,6 +525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +566,53 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge port cold weather mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_port_cold_weather_mode', + 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -331,6 +620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -367,7 +657,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -377,6 +667,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -417,6 +708,335 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DC to DC converter', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dc_dc_enable', + 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_drive_rail-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_drive_rail', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Drive rail', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_rail', + 'unique_id': 'LRW3F7EK4NC700000-drive_rail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_drive_rail-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Driver seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Driver seat occupied', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'driver_seat_occupied', + 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Emergency lane departure avoidance', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'emergency_lane_departure_avoidance', + 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_european_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_european_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'European vehicle', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'europe_vehicle', + 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fast charger present', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fast_charger_present', + 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -424,6 +1044,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,7 +1082,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -471,6 +1092,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,7 +1130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -518,6 +1140,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -555,7 +1178,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -565,6 +1188,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -602,7 +1226,290 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_gps_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GPS state', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gps_state', + 'unique_id': 'LRW3F7EK4NC700000-gps_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_gps_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guest mode enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'guest_mode_enabled', + 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Homelink nearby', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink_nearby', + 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Offroad lightbar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'offroad_lightbar_present', + 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Passenger seat belt', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'passenger_seat_belt', + 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pin to drive enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pin_to_drive_enabled', + 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -612,6 +1519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +1566,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -694,7 +1603,54 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rear display HVAC', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rear_display_hvac_enabled', + 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -704,6 +1660,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -741,7 +1698,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -751,6 +1708,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,7 +1746,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -798,6 +1756,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -835,7 +1794,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -845,6 +1804,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -882,7 +1842,54 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Right hand drive', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'right_hand_drive', + 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -892,6 +1899,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -928,7 +1936,54 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_service_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Service mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_mode', + 'unique_id': 'LRW3F7EK4NC700000-service_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_service_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -938,6 +1993,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -978,6 +2034,53 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supercharger session trip planner', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supercharger_session_trip_planner', + 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -985,6 +2088,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +2136,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1079,6 +2184,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1126,6 +2232,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1173,6 +2280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1219,6 +2327,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1259,6 +2368,53 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.test_wiper_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_wiper_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wiper heat', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wiper_heat_enabled', + 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1311,6 +2467,32 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic blind spot camera', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Automatic emergency braking off', + }), + 'context': , + 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1322,17 +2504,56 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_actively_cooling-statealt] +# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Blind spot collision warning chime', + }), + 'context': , + 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test BMS full charge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_bms_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Brake pedal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_brake_pedal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', + 'friendly_name': 'Test Cabin overheat protection active', }), 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_active', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1353,6 +2574,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge port cold weather mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1363,7 +2597,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -1380,6 +2614,97 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test DC to DC converter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dc_to_dc_converter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Drive rail', + }), + 'context': , + 'entity_id': 'binary_sensor.test_drive_rail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Driver seat occupied', + }), + 'context': , + 'entity_id': 'binary_sensor.test_driver_seat_occupied', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Emergency lane departure avoidance', + }), + 'context': , + 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test European vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_european_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fast charger present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_fast_charger_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1391,7 +2716,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -1405,7 +2730,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -1419,7 +2744,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -1433,7 +2758,86 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test GPS state', + }), + 'context': , + 'entity_id': 'binary_sensor.test_gps_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Guest mode enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_guest_mode_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink nearby', + }), + 'context': , + 'entity_id': 'binary_sensor.test_homelink_nearby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Offroad lightbar', + }), + 'context': , + 'entity_id': 'binary_sensor.test_offroad_lightbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Passenger seat belt', + }), + 'context': , + 'entity_id': 'binary_sensor.test_passenger_seat_belt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pin to drive enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -1459,7 +2863,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Rear display HVAC', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_display_hvac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -1473,7 +2890,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -1487,7 +2904,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -1501,7 +2918,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -1515,7 +2932,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Right hand drive', + }), + 'context': , + 'entity_id': 'binary_sensor.test_right_hand_drive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -1528,7 +2958,20 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Service mode', + }), + 'context': , + 'entity_id': 'binary_sensor.test_service_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -1545,6 +2988,19 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Supercharger session trip planner', + }), + 'context': , + 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1628,3 +3084,31 @@ 'state': 'on', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wiper heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_wiper_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_driver_seat_belt-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_driver_window-state] + 'on' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_door-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'on' +# --- diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr index 6d3016186ae..e4e20215020 100644 --- a/tests/components/teslemetry/snapshots/test_button.ambr +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 7064309e98b..4c265c00cb8 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -21,6 +21,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -91,6 +92,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +164,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -231,6 +234,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,6 +304,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -339,6 +344,7 @@ 'min_temp': 15.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 24e1b02a5f8..9548a911cf9 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,54 +342,6 @@ 'state': 'open', }) # --- -# name: test_cover_alt[cover.test_sunroof-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.test_sunroof', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Sunroof', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'vehicle_state_sun_roof_state', - 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_cover_alt[cover.test_sunroof-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Sunroof', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_sunroof', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_cover_alt[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -390,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -486,6 +447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +496,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -582,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -678,6 +643,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -719,3 +685,39 @@ 'state': 'closed', }) # --- +# name: test_cover_streaming[cover.test_charge_port_door-closed] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-open] + 'closed' +# --- +# name: test_cover_streaming[cover.test_charge_port_door-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_frunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_trunk-unknown] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-closed] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-open] + 'unknown' +# --- +# name: test_cover_streaming[cover.test_windows-unknown] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index ac4c388873f..b9e381ee42d 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,3 +135,21 @@ 'state': 'not_home', }) # --- +# name: test_device_tracker_streaming[device_tracker.test_location-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_location-state] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-restore] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_origin-state] + 'unknown' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-restore] + 'not_home' +# --- +# name: test_device_tracker_streaming[device_tracker.test_route-state] + 'home' +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 3b96d6f70c0..56a8f759a21 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -3,6 +3,29 @@ dict({ 'energysites': list([ dict({ + 'history': dict({ + 'battery_energy_exported': 36, + 'battery_energy_imported_from_generator': 0, + 'battery_energy_imported_from_grid': 0, + 'battery_energy_imported_from_solar': 684, + 'consumer_energy_imported_from_battery': 36, + 'consumer_energy_imported_from_generator': 0, + 'consumer_energy_imported_from_grid': 0, + 'consumer_energy_imported_from_solar': 38, + 'generator_energy_exported': 0, + 'grid_energy_exported_from_battery': 0, + 'grid_energy_exported_from_generator': 0, + 'grid_energy_exported_from_solar': 2, + 'grid_energy_imported': 0, + 'grid_services_energy_exported': 0, + 'grid_services_energy_imported': 0, + 'solar_energy_exported': 724, + 'total_battery_charge': 684, + 'total_battery_discharge': 36, + 'total_grid_energy_exported': 2, + 'total_home_usage': 74, + 'total_solar_generation': 724, + }), 'info': dict({ 'backup_reserve_percent': 0, 'battery_count': 2, @@ -352,7 +375,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', + 'vehicle_state_car_version': '2024.44.25 06f534d46010', 'vehicle_state_center_display_state': 0, 'vehicle_state_dashcam_clip_save_available': True, 'vehicle_state_dashcam_state': 'Recording', @@ -432,6 +455,13 @@ 'vehicle_state_webcam_available': True, 'vin': '**REDACTED**', }), + 'stream': dict({ + 'config': dict({ + 'fields': dict({ + }), + 'prefer_typed': None, + }), + }), }), ]), }) diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index 7d60ed82859..f1011034d63 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index 2130c4d9574..d6b29f0d7d4 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -93,3 +95,111 @@ 'state': 'unlocked', }) # --- +# name: test_lock_alt[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge cable lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_alt[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-unlocked] + 'unlocked' +# --- +# name: test_lock_streaming[lock.test_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_lock-unlocked] + 'unlocked' +# --- diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index a9d2569c637..663e91a502c 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -84,6 +85,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 0f30daf635e..5ca9feb22f2 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -229,3 +233,9 @@ 'state': '80', }) # --- +# name: test_number_streaming[number.test_charge_current-state] + '24' +# --- +# name: test_number_streaming[number.test_charge_limit-state] + '99' +# --- diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 0c2547f309d..755a1a82c41 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +309,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -408,3 +415,79 @@ 'state': 'off', }) # --- +# name: test_select[select.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steering wheel heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heat_level', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_steering_wheel_heat_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Steering wheel heater', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_streaming[select.test_seat_heater_front_left] + 'off' +# --- +# name: test_select_streaming[select.test_seat_heater_front_right] + 'low' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_center] + 'unknown' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_left] + 'medium' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_right] + 'high' +# --- +# name: test_select_streaming[select.test_steering_wheel_heater] + 'off' +# --- diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index acff157bfea..c5d98abc95c 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,6 +304,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -373,6 +378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -519,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -592,6 +600,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -738,6 +748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -811,6 +822,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -884,6 +896,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +970,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1030,6 +1044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1176,6 +1192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1249,6 +1266,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1322,6 +1340,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1395,6 +1414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1468,6 +1488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1541,6 +1562,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1614,6 +1636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1687,6 +1710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1766,6 +1790,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1843,6 +1868,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1916,6 +1942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1986,6 +2013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2059,6 +2087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2132,6 +2161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2205,6 +2235,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2276,6 +2307,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2335,6 +2367,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2400,6 +2433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2414,6 +2448,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -2467,6 +2504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2538,6 +2576,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2599,6 +2638,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2669,6 +2709,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2739,6 +2780,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2806,6 +2848,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2873,6 +2916,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2947,6 +2991,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3026,6 +3071,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3096,6 +3142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3166,6 +3213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3237,6 +3285,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3298,6 +3347,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3371,6 +3421,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3441,6 +3492,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3514,6 +3566,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3584,6 +3637,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3654,6 +3708,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3726,6 +3781,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3801,6 +3857,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3843,7 +3900,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_speed-statealt] @@ -3859,7 +3916,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] @@ -3871,6 +3928,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3910,7 +3968,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_state_of_charge_at_arrival-statealt] @@ -3926,7 +3984,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.test_time_to_arrival-entry] @@ -3936,6 +3994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3997,6 +4056,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4060,6 +4120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4133,6 +4194,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4206,6 +4268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4279,6 +4342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4352,6 +4416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4419,6 +4484,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4484,6 +4550,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4543,6 +4610,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4604,6 +4672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4677,6 +4746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4748,6 +4818,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4807,6 +4878,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4866,6 +4938,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4925,6 +4998,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4977,3 +5051,24 @@ 'state': 'unknown', }) # --- +# name: test_sensors_streaming[sensor.test_battery_level-state] + '90' +# --- +# name: test_sensors_streaming[sensor.test_charge_cable-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_charge_energy_added-state] + '10' +# --- +# name: test_sensors_streaming[sensor.test_charger_power-state] + '2' +# --- +# name: test_sensors_streaming[sensor.test_charging-state] + 'charging' +# --- +# name: test_sensors_streaming[sensor.test_time_to_arrival-state] + 'unknown' +# --- +# name: test_sensors_streaming[sensor.test_time_to_full_charge-state] + 'unknown' +# --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index 5693d4bdd5e..f9997133044 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -262,7 +268,7 @@ 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +284,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,7 +464,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 0777f4ccdb9..1c7d525af86 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -40,7 +41,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', + 'installed_version': '2024.44.25', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, @@ -54,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_update_alt[update.test_update-entry] @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,8 +100,8 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2023.44.30.8', - 'latest_version': '2023.44.30.8', + 'installed_version': '2024.44.25', + 'latest_version': '2024.44.25', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0a47dce9537..5a7126afe1b 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -48,3 +49,58 @@ async def test_binary_sensor_refresh( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "INVALID_VALUE", + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + Signal.DRIVER_SEAT_BELT: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "binary_sensor.test_front_driver_window", + "binary_sensor.test_front_passenger_window", + "binary_sensor.test_front_driver_door", + "binary_sensor.test_front_passenger_door", + "binary_sensor.test_driver_seat_belt", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 04edf668765..75f94342f1e 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -29,6 +29,7 @@ async def test_button( @pytest.mark.parametrize( ("name", "func"), [ + ("wake", "wake_up"), ("flash_lights", "flash_lights"), ("honk_horn", "honk_horn"), ("keyless_driving", "remote_start_drive"), diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 7dbdcfa5747..14af1e732fe 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, @@ -25,6 +26,7 @@ async def test_cover( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" @@ -38,6 +40,7 @@ async def test_cover_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct with alternate values.""" @@ -52,6 +55,7 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -215,3 +219,127 @@ async def test_cover_services( state = hass.states.get(entity_id) assert state assert state.state == CoverState.CLOSED + + +async def test_cover_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the binary sensor entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateClosed", + Signal.FP_WINDOW: "WindowStateClosed", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStateClosed", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": False, + "DriverRear": False, + "PassengerFront": False, + "PassengerRear": False, + "TrunkFront": False, + "TrunkRear": False, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-closed") + + # Send some alternative data with everything open + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateOpened", + Signal.FP_WINDOW: "WindowStateOpened", + Signal.RD_WINDOW: "WindowStateOpened", + Signal.RP_WINDOW: "WindowStateOpened", + Signal.CHARGE_PORT_DOOR_OPEN: False, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": True, + "DriverRear": True, + "PassengerFront": True, + "PassengerRear": True, + "TrunkFront": True, + "TrunkRear": True, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get new values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-open") + + # Send some alternative data with everything unknown + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.FD_WINDOW: "WindowStateUnknown", + Signal.FP_WINDOW: "WindowStateUnknown", + Signal.RD_WINDOW: "WindowStateUnknown", + Signal.RP_WINDOW: "WindowStateUnknown", + Signal.CHARGE_PORT_DOOR_OPEN: None, + Signal.DOOR_STATE: { + "DoorState": { + "DriverFront": None, + "DriverRear": None, + "PassengerFront": None, + "PassengerRear": None, + "TrunkFront": None, + "TrunkRear": None, + } + }, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities get UNKNOWN values + for entity_id in ( + "cover.test_windows", + "cover.test_charge_port_door", + "cover.test_frunk", + "cover.test_trunk", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unknown") diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index d86c3ca8596..38a28092d33 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -12,10 +14,12 @@ from . import assert_entities, assert_entities_alt, setup_platform from .const import VEHICLE_DATA_ALT +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" @@ -23,14 +27,71 @@ async def test_device_tracker( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_tracker_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" mock_vehicle_data.return_value = VEHICLE_DATA_ALT entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the device tracker entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCATION: { + "latitude": 1.0, + "longitude": 2.0, + }, + Signal.DESTINATION_LOCATION: { + "latitude": 3.0, + "longitude": 4.0, + }, + Signal.DESTINATION_NAME: "Home", + Signal.ORIGIN_LOCATION: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "device_tracker.test_location", + "device_tracker.test_route", + "device_tracker.test_origin", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-restore") diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 6d4e04c21b4..5481e6cc034 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -142,13 +142,13 @@ async def test_energy_history_refresh_error( async def test_vehicle_stream( hass: HomeAssistant, - mock_listen: AsyncMock, + mock_add_listener: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test vehicle stream events.""" - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - mock_listen.assert_called_once() + await setup_platform(hass, [Platform.BINARY_SENSOR]) + mock_add_listener.assert_called() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_ON @@ -156,29 +156,37 @@ async def test_vehicle_stream( state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_OFF - runtime_data: TeslemetryData = entry.runtime_data - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "vehicle_data": VEHICLE_DATA_ALT["response"], - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "vehicle_data": VEHICLE_DATA_ALT["response"], + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_user_present") assert state.state == STATE_ON - for listener, _ in runtime_data.vehicles[0].stream._listeners.values(): - listener( - { - "vin": VEHICLE_DATA_ALT["response"]["vin"], - "state": "offline", - "createdAt": "2024-10-04T10:45:17.537Z", - } - ) + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "state": "offline", + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_status") assert state.state == STATE_OFF + + +async def test_no_live_status( + hass: HomeAssistant, + mock_live_status: AsyncMock, +) -> None: + """Test coordinator refresh with an error.""" + mock_live_status.side_effect = AsyncMock({"response": ""}) + await setup_platform(hass) + + assert hass.states.get("sensor.energy_site_grid_power") is None diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index f7c9fea1400..848eee82c39 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -1,9 +1,10 @@ """Test the Teslemetry lock platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -16,14 +17,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform -from .const import COMMAND_OK +from . import assert_entities, reload_platform, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT async def test_lock( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the lock entities are correct.""" @@ -31,6 +33,20 @@ async def test_lock( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +async def test_lock_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that the lock entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.LOCK]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + async def test_lock_services( hass: HomeAssistant, ) -> None: @@ -91,3 +107,60 @@ async def test_lock_services( state = hass.states.get(entity_id) assert state.state == LockState.UNLOCKED call.assert_called_once() + + +async def test_lock_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the lock entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.LOCK]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: True, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchEngaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-locked") + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: False, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchDisengaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unlocked") diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 65c03514d22..95eed5a3f1e 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.number import ( ATTR_VALUE, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -23,6 +24,7 @@ async def test_number( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the number entities are correct.""" @@ -100,3 +102,38 @@ async def test_number_services( state = hass.states.get(entity_id) assert state.state == "88" call.assert_called_once() + + +async def test_number_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the number entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.NUMBER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CHARGE_CURRENT_REQUEST: 24, + Signal.CHARGE_CURRENT_REQUEST_MAX: 32, + Signal.CHARGE_LIMIT_SOC: 99, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.NUMBER]) + + # Assert the entities restored their values + for entity_id in ( + "number.test_charge_current", + "number.test_charge_limit", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index 005a6a2004e..c49e83803cd 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from teslemetry_stream.const import Signal from homeassistant.components.select import ( ATTR_OPTION, @@ -16,7 +17,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -25,6 +26,7 @@ async def test_select( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities are correct.""" @@ -106,6 +108,7 @@ async def test_select_invalid_data( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities handle invalid data.""" @@ -119,3 +122,45 @@ async def test_select_invalid_data( assert state.state == STATE_UNKNOWN state = hass.states.get("select.test_steering_wheel_heater") assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SELECT]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SEAT_HEATER_LEFT: 0, + Signal.SEAT_HEATER_RIGHT: 1, + Signal.SEAT_HEATER_REAR_LEFT: 2, + Signal.SEAT_HEATER_REAR_RIGHT: 3, + Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: 0, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.SELECT]) + + # Assert the entities restored their values + for entity_id in ( + "select.test_seat_heater_front_left", + "select.test_seat_heater_front_right", + "select.test_seat_heater_rear_left", + "select.test_seat_heater_rear_center", + "select.test_seat_heater_rear_right", + "select.test_steering_wheel_heater", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f0b472a7183..a488ebc8a06 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,10 +1,11 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform @@ -25,11 +26,15 @@ async def test_sensors( freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, ) -> None: - """Tests that the sensor entities are correct.""" + """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") - entry = await setup_platform(hass, [Platform.SENSOR]) + # Force the vehicle to use polling + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + ): + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) @@ -40,3 +45,54 @@ async def test_sensors( await hass.async_block_till_done() assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the sensor entities with streaming are correct.""" + + freezer.move_to("2024-01-01 00:00:00+00:00") + + entry = await setup_platform(hass, [Platform.SENSOR]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + Signal.BATTERY_LEVEL: 90, + Signal.AC_CHARGING_ENERGY_IN: 10, + Signal.AC_CHARGING_POWER: 2, + Signal.CHARGING_CABLE_TYPE: None, + Signal.TIME_TO_FULL_CHARGE: 10, + Signal.MINUTES_TO_ARRIVAL: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Assert the entities restored their values + for entity_id in ( + "sensor.test_charging", + "sensor.test_battery_level", + "sensor.test_charge_energy_added", + "sensor.test_charger_power", + "sensor.test_charge_cable", + "sensor.test_time_to_full_charge", + "sensor.test_time_to_arrival", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") diff --git a/tests/components/tessie/snapshots/test_binary_sensor.ambr b/tests/components/tessie/snapshots/test_binary_sensor.ambr index 6c0da044df2..2fe97b88811 100644 --- a/tests/components/tessie/snapshots/test_binary_sensor.ambr +++ b/tests/components/tessie/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,6 +383,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +431,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +479,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -516,6 +527,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -563,6 +575,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -610,6 +623,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -657,6 +671,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -751,6 +767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -844,6 +862,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -891,6 +910,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -938,6 +958,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -985,6 +1006,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1054,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1078,6 +1101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1125,6 +1149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1172,6 +1197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1219,6 +1245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1266,6 +1293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1313,6 +1341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1359,6 +1388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_button.ambr b/tests/components/tessie/snapshots/test_button.ambr index 7757d1f2fea..96ece94a1c9 100644 --- a/tests/components/tessie/snapshots/test_button.ambr +++ b/tests/components/tessie/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr index 959b42cea53..415988e783e 100644 --- a/tests/components/tessie/snapshots/test_climate.ambr +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr index 6338758afb7..fdf2a967048 100644 --- a/tests/components/tessie/snapshots/test_cover.ambr +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 61f89db8637..92502340aa2 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_lock.ambr b/tests/components/tessie/snapshots/test_lock.ambr index cea2bebbddb..f819281d79b 100644 --- a/tests/components/tessie/snapshots/test_lock.ambr +++ b/tests/components/tessie/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index 6c355c8ddca..911598004a6 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index 6e641bdf5b7..0e43695ca78 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +245,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_select.ambr b/tests/components/tessie/snapshots/test_select.ambr index acc1946aab5..f118633aded 100644 --- a/tests/components/tessie/snapshots/test_select.ambr +++ b/tests/components/tessie/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +309,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 0a5ff4603aa..5465f89d808 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -350,6 +356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -404,6 +411,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +469,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -516,6 +525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -566,6 +576,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -617,6 +628,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -674,6 +686,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +744,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,6 +802,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -842,6 +857,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -896,6 +912,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -947,6 +964,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -998,6 +1016,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1056,6 +1075,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1111,6 +1131,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1159,6 +1180,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1213,6 +1235,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1267,6 +1290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1321,6 +1345,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1378,6 +1403,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1432,6 +1458,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1486,6 +1513,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1542,6 +1570,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1597,6 +1626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1651,6 +1681,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1700,6 +1731,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1747,6 +1779,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1796,6 +1829,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1853,6 +1887,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1910,6 +1945,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1967,6 +2003,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2024,6 +2061,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2075,6 +2113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2132,6 +2171,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2200,6 +2240,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2272,6 +2313,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2331,6 +2373,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2377,6 +2420,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index 3b7a3623de8..371ef822122 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -119,7 +122,7 @@ 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', 'unit_of_measurement': None, }) @@ -145,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -351,6 +358,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index 1728c13b0ad..e4c25e2230f 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 49a53fd327c..02a8f22b6ea 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -112,4 +112,4 @@ async def test_errors(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() - assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR["reason"]}" + assert str(error.value) == f"Command failed, {TEST_RESPONSE_ERROR['reason']}" diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 499e529b2e8..690ad7d1ab4 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -20,7 +20,7 @@ from .common import RESPONSE_OK, assert_entities, setup_platform async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry ) -> None: - """Tests that the switche entities are correct.""" + """Tests that the switch entities are correct.""" entry = await setup_platform(hass, [Platform.SWITCH]) diff --git a/tests/components/thermobeacon/test_config_flow.py b/tests/components/thermobeacon/test_config_flow.py index a26a2b70c5e..2194168c25d 100644 --- a/tests/components/thermobeacon/test_config_flow.py +++ b/tests/components/thermobeacon/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=THERMOBEACON_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.thermobeacon.config_flow.async_discovered_service_info", + return_value=[THERMOBEACON_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.thermobeacon.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Lanyard/mini hygrometer EEFF" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index 264e556756c..d3cba26858f 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -23,6 +23,16 @@ TP357_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +TP358_SERVICE_INFO = BluetoothServiceInfo( + name="TP358 (4221)", + manufacturer_data={61890: b"\x00\x1d\x02,"}, + service_uuids=[], + address="aa:bb:cc:dd:ee:ff", + rssi=-65, + service_data={}, + source="local", +) + TP962R_SERVICE_INFO = BluetoothServiceInfo( name="TP962R (0000)", manufacturer_data={14081: b"\x00;\x0b7\x00"}, diff --git a/tests/components/thermopro/conftest.py b/tests/components/thermopro/conftest.py index 445f52b7844..0dcc03ae7f4 100644 --- a/tests/components/thermopro/conftest.py +++ b/tests/components/thermopro/conftest.py @@ -1,8 +1,64 @@ """ThermoPro session fixtures.""" +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + import pytest +from thermopro_ble import ThermoProDevice + +from homeassistant.components.thermopro.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import now + +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture +def dummy_thermoprodevice(monkeypatch: pytest.MonkeyPatch) -> ThermoProDevice: + """Mock for downstream library.""" + client = ThermoProDevice("") + monkeypatch.setattr(client, "set_datetime", AsyncMock()) + return client + + +@pytest.fixture +def mock_thermoprodevice( + monkeypatch: pytest.MonkeyPatch, dummy_thermoprodevice: ThermoProDevice +) -> ThermoProDevice: + """Return downstream library mock.""" + monkeypatch.setattr( + "homeassistant.components.thermopro.button.ThermoProDevice", + MagicMock(return_value=dummy_thermoprodevice), + ) + return dummy_thermoprodevice + + +@pytest.fixture +def mock_now(monkeypatch: pytest.MonkeyPatch) -> datetime: + """Return fixed datetime for comparison.""" + fixed_now = now() + monkeypatch.setattr( + "homeassistant.components.thermopro.button.now", + MagicMock(return_value=fixed_now), + ) + return fixed_now + + +@pytest.fixture +async def setup_thermopro( + hass: HomeAssistant, mock_thermoprodevice: ThermoProDevice +) -> None: + """Set up the Thermopro integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/thermopro/test_button.py b/tests/components/thermopro/test_button.py new file mode 100644 index 00000000000..e4c73af11be --- /dev/null +++ b/tests/components/thermopro/test_button.py @@ -0,0 +1,135 @@ +"""Test the ThermoPro button platform.""" + +from datetime import datetime, timedelta +import time + +import pytest +from thermopro_ble import ThermoProDevice + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import TP357_SERVICE_INFO, TP358_SERVICE_INFO + +from tests.common import async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, + patch_bluetooth_time, +) + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp357(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP357_SERVICE_INFO) + await hass.async_block_till_done() + assert not hass.states.get("button.tp358_4221_set_date_time") + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_discovery(hass: HomeAssistant) -> None: + """Test discovery of device with button.""" + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + assert button is not None + assert button.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_unavailable(hass: HomeAssistant) -> None: + """Test tp358 set date&time button goes to unavailability.""" + start_monotonic = time.monotonic() + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + assert button is not None + assert button.state == STATE_UNKNOWN + + # Fast-forward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15 + + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15), + ) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + + assert button.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_reavailable(hass: HomeAssistant) -> None: + """Test TP358/TP393 set date&time button goes to unavailablity and recovers.""" + start_monotonic = time.monotonic() + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + assert button is not None + assert button.state == STATE_UNKNOWN + + # Fast-forward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15 + + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15), + ) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + + assert button.state == STATE_UNAVAILABLE + + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + + assert button.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_press( + hass: HomeAssistant, mock_now: datetime, mock_thermoprodevice: ThermoProDevice +) -> None: + """Test TP358/TP393 set date&time button press.""" + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + assert hass.states.get("button.tp358_4221_set_date_time") + + await hass.services.async_call( + "button", + "press", + {ATTR_ENTITY_ID: "button.tp358_4221_set_date_time"}, + blocking=True, + ) + + mock_thermoprodevice.set_datetime.assert_awaited_once_with(mock_now, am_pm=False) + + button_state = hass.states.get("button.tp358_4221_set_date_time") + assert button_state.state != STATE_UNKNOWN diff --git a/tests/components/thermopro/test_config_flow.py b/tests/components/thermopro/test_config_flow.py index 9b9fdd67334..3cf68fb612c 100644 --- a/tests/components/thermopro/test_config_flow.py +++ b/tests/components/thermopro/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info and replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TP357_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.thermopro.config_flow.async_discovered_service_info", + return_value=[TP357_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.thermopro.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "4125DDBA-2774-4851-9889-6AADDD4CAC3D"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "TP357 (2142) AC3D" + assert result2["data"] == {} + assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index c31a1937d45..7feefdafedf 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -3,11 +3,12 @@ from ipaddress import ip_address from unittest.mock import patch -from homeassistant.components import thread, zeroconf +from homeassistant.components import thread from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( +TEST_ZEROCONF_RECORD = ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index d817c9612aa..845df86a88c 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -10,10 +10,13 @@ from homeassistant.util import dt as dt_util from .test_common import CONSUMPTION_DATA_1, PRODUCTION_DATA_1, mock_get_homes +from tests.common import MockConfigEntry from tests.components.recorder.common import async_wait_recording_done -async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_async_setup_entry( + recorder_mock: Recorder, hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup Tibber.""" tibber_connection = AsyncMock() tibber_connection.name = "tibber" @@ -21,7 +24,7 @@ async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) - tibber_connection.fetch_production_data_active_homes.return_value = None tibber_connection.get_homes = mock_get_homes - coordinator = TibberDataCoordinator(hass, tibber_connection) + coordinator = TibberDataCoordinator(hass, config_entry, tibber_connection) await coordinator._async_update_data() await async_wait_recording_done(hass) diff --git a/tests/components/tile/snapshots/test_binary_sensor.ambr b/tests/components/tile/snapshots/test_binary_sensor.ambr index 5f72f53fa1e..6de356ebf51 100644 --- a/tests/components/tile/snapshots/test_binary_sensor.ambr +++ b/tests/components/tile/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index 15108331e66..f5de1511c99 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index 90f165d1e6e..ffdf6a6251a 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tilt_ble/test_config_flow.py b/tests/components/tilt_ble/test_config_flow.py index fd996228034..9c9450f3996 100644 --- a/tests/components/tilt_ble/test_config_flow.py +++ b/tests/components/tilt_ble/test_config_flow.py @@ -79,6 +79,37 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "F6:0F:28:F2:1F:CB" +async def test_async_step_user_replaces_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TILT_GREEN_SERVICE_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.tilt_ble.config_flow.async_discovered_service_info", + return_value=[TILT_GREEN_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.tilt_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "F6:0F:28:F2:1F:CB"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tilt Green" + assert result2["data"] == {} + assert result2["result"].unique_id == "F6:0F:28:F2:1F:CB" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index ddeec48b3d2..3daa0314cbd 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.time_date.const import OPTION_TYPES from homeassistant.core import HomeAssistant from homeassistant.helpers import event -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import load_int diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 95baa07eaa9..6e68b354087 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -196,6 +196,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_CANCELLED, "data": {}, }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": None, + "data": {}, + }, { "call": SERVICE_START, "state": STATUS_ACTIVE, @@ -208,6 +214,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_FINISHED, "data": {}, }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": None, + "data": {}, + }, { "call": SERVICE_START, "state": STATUS_ACTIVE, @@ -244,6 +256,18 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_RESTARTED, "data": {}, }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": EVENT_TIMER_FINISHED, + "data": {}, + }, ] expected_events = 0 diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index b4b6b13d8e3..8b9a81d7542 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -7,10 +7,10 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py index 0138e561fad..53772ab144e 100644 --- a/tests/components/todo/__init__.py +++ b/tests/components/todo/__init__.py @@ -3,7 +3,7 @@ from homeassistant.components.todo import DOMAIN, TodoItem, TodoListEntity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import MockConfigEntry, MockPlatform, mock_platform @@ -44,7 +44,7 @@ async def create_mock_platform( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4b2bfea2e30..84f0fa740e9 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -70,6 +70,7 @@ def make_api_task( section_id=None, url="https://todoist.com", sync_id=None, + duration=None, ) @@ -94,6 +95,7 @@ def mock_api(tasks: list[Task]) -> AsyncMock: url="", is_inbox_project=False, is_team_inbox=False, + can_assign_tasks=False, order=1, parent_id=None, view_style="list", diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 73382944cf0..e918edf70a4 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -5,14 +5,14 @@ from unittest.mock import Mock, patch import pytest from tololib import ToloCommunicationError -from homeassistant.components import dhcp from homeassistant.components.tolo.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( +MOCK_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="mock_hostname" ) diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index f50d999548f..e4f08f55dba 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -8,7 +8,7 @@ import requests_mock import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.tomato.device_tracker as tomato +from homeassistant.components.tomato import device_tracker as tomato from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 828cad71e07..34d451ec0b8 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -49,20 +49,15 @@ USER = { "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", } -RESPONSE_AUTHENTICATE = { +RESPONSE_SESSION_DETAILS = { "ResultCode": ResultCode.SUCCESS.value, - "SessionID": 1, + "ResultData": "Success", + "SessionID": "12345", "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } -RESPONSE_AUTHENTICATE_FAILED = { - "ResultCode": ResultCode.BAD_USER_OR_PASSWORD.value, - "ResultData": "test bad authentication", -} - - PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": ArmingState.DISARMED, @@ -359,13 +354,13 @@ OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} PARTITION_DETAILS_1 = { - "PartitionID": 1, + "PartitionID": "1", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test1", } PARTITION_DETAILS_2 = { - "PartitionID": 2, + "PartitionID": "2", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test2", } @@ -402,6 +397,12 @@ RESPONSE_GET_ZONE_DETAILS_SUCCESS = { TOTALCONNECT_REQUEST = ( "homeassistant.components.totalconnect.TotalConnectClient.request" ) +TOTALCONNECT_GET_CONFIG = ( + "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" +) +TOTALCONNECT_REQUEST_TOKEN = ( + "homeassistant.components.totalconnect.TotalConnectClient._request_token" +) async def setup_platform( @@ -420,7 +421,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -433,6 +434,8 @@ async def setup_platform( TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), ): assert await async_setup_component(hass, DOMAIN, {}) assert mock_request.call_count == 5 @@ -448,17 +451,21 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, RESPONSE_DISARMED, ] - with patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request: + with ( + patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): await hass.config_entries.async_setup(mock_entry.entry_id) assert mock_request.call_count == 5 await hass.async_block_till_done() diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index ef7cb386b33..a63319a6c76 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr index 1eccff1dfc3..ac79455a0d5 100644 --- a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr +++ b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -206,6 +210,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -306,6 +312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -356,6 +363,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -406,6 +414,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,6 +465,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -506,6 +516,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -556,6 +567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +618,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -656,6 +669,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -706,6 +720,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -756,6 +771,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -806,6 +822,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -854,6 +871,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -902,6 +920,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -949,6 +968,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -997,6 +1017,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1045,6 +1066,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1093,6 +1115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1143,6 +1166,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1193,6 +1217,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr index af3318591c6..96d38567236 100644 --- a/tests/components/totalconnect/snapshots/test_button.ambr +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 86419bff817..f5020394bce 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -18,13 +18,15 @@ from homeassistant.data_entry_flow import FlowResultType from .common import ( CONFIG_DATA, CONFIG_DATA_NO_USERCODES, - RESPONSE_AUTHENTICATE, RESPONSE_DISARMED, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_PARTITION_DETAILS, + RESPONSE_SESSION_DETAILS, RESPONSE_SUCCESS, RESPONSE_USER_CODE_INVALID, + TOTALCONNECT_GET_CONFIG, TOTALCONNECT_REQUEST, + TOTALCONNECT_REQUEST_TOKEN, USERNAME, ) @@ -48,7 +50,7 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: """Test user locations form.""" # user/pass provided, so check if valid then ask for usercodes on locations form responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -61,6 +63,8 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -180,7 +184,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_no_locations(hass: HomeAssistant) -> None: """Test with no user locations.""" responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -191,6 +195,8 @@ async def test_no_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -221,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -229,7 +235,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses): + with ( + patch(TOTALCONNECT_REQUEST, side_effect=responses), + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index e322cf9f5de..ac5bb347765 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -2,178 +2,50 @@ from collections import namedtuple from dataclasses import replace -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( BaseProtocol, Device, - DeviceConfig, - DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, DeviceType, Feature, KasaException, Module, + ThermostatState, ) -from kasa.interfaces import Fan, Light, LightEffect, LightState +from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat +from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm +from kasa.smart.modules.clean import AreaUnit, Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN -from homeassistant.components.tplink import ( - CONF_AES_KEYS, - CONF_ALIAS, - CONF_CAMERA_CREDENTIALS, - CONF_CONNECTION_PARAMETERS, - CONF_CREDENTIALS_HASH, - CONF_HOST, - CONF_LIVE_VIEW, - CONF_MODEL, - CONF_USES_HTTP, - Credentials, -) from homeassistant.components.tplink.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component +from .const import ( + ALIAS, + CREDENTIALS_HASH_LEGACY, + DEVICE_CONFIG_LEGACY, + DEVICE_ID, + IP_ADDRESS, + MAC_ADDRESS, + MODEL, +) + from tests.common import MockConfigEntry, load_json_value_fixture ColorTempRange = namedtuple("ColorTempRange", ["min", "max"]) # noqa: PYI024 -MODULE = "homeassistant.components.tplink" -MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" -IP_ADDRESS = "127.0.0.1" -IP_ADDRESS2 = "127.0.0.2" -IP_ADDRESS3 = "127.0.0.3" -ALIAS = "My Bulb" -ALIAS_CAMERA = "My Camera" -MODEL = "HS100" -MODEL_CAMERA = "C210" -MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" -DEVICE_ID = "123456789ABCDEFGH" -DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" -DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") -MAC_ADDRESS2 = "11:22:33:44:55:66" -MAC_ADDRESS3 = "66:55:44:33:22:11" -DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" -DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" -CREDENTIALS_HASH_LEGACY = "" -CONN_PARAMS_LEGACY = DeviceConnectionParameters( - DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor -) -DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) -DEVICE_CONFIG_DICT_LEGACY = { - k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" -} -CREDENTIALS = Credentials("foo", "bar") -CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" -CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" -CONN_PARAMS_KLAP = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap -) -DEVICE_CONFIG_KLAP = DeviceConfig( - IP_ADDRESS, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_KLAP, - uses_http=True, -) -CONN_PARAMS_AES = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes -) -_test_privkey = ( - "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" - "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" - "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" - "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" - "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" - "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" - "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" - "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" - "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" - "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" - "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" - "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" -) -_test_pubkey = ( - "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" - "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" - "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" -) -AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} -DEVICE_CONFIG_AES = DeviceConfig( - IP_ADDRESS2, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES, - uses_http=True, - aes_keys=AES_KEYS, -) -CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( - DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 -) -DEVICE_CONFIG_AES_CAMERA = DeviceConfig( - IP_ADDRESS3, - credentials=CREDENTIALS, - connection_type=CONN_PARAMS_AES_CAMERA, - uses_http=True, -) - -DEVICE_CONFIG_DICT_KLAP = { - k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" -} -DEVICE_CONFIG_DICT_AES = { - k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" -} -CREATE_ENTRY_DATA_LEGACY = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), - CONF_USES_HTTP: False, -} - -CREATE_ENTRY_DATA_KLAP = { - CONF_HOST: IP_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), - CONF_USES_HTTP: True, -} -CREATE_ENTRY_DATA_AES = { - CONF_HOST: IP_ADDRESS2, - CONF_ALIAS: ALIAS, - CONF_MODEL: MODEL, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), - CONF_USES_HTTP: True, - CONF_AES_KEYS: AES_KEYS, -} -CREATE_ENTRY_DATA_AES_CAMERA = { - CONF_HOST: IP_ADDRESS3, - CONF_ALIAS: ALIAS_CAMERA, - CONF_MODEL: MODEL_CAMERA, - CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), - CONF_USES_HTTP: True, - CONF_LIVE_VIEW: True, - CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, -} -SMALLEST_VALID_JPEG = ( - "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" - "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" - "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" -) -SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) - def _load_feature_fixtures(): fixtures = load_json_value_fixture("features.json", DOMAIN) @@ -188,6 +60,7 @@ def _load_feature_fixtures(): FEATURES_FIXTURE = _load_feature_fixtures() +FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode, "CleanAreaUnit": AreaUnit} async def setup_platform_for_device( @@ -201,7 +74,7 @@ async def setup_platform_for_device( _patch_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) # Good practice to wait background tasks in tests see PR #112726 await hass.async_block_till_done(wait_background_tasks=True) @@ -217,15 +90,15 @@ async def snapshot_platform( device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) assert device_entries for device_entry in device_entries: - assert device_entry == snapshot( - name=f"{device_entry.name}-entry" - ), f"device entry snapshot failed for {device_entry.name}" + assert device_entry == snapshot(name=f"{device_entry.name}-entry"), ( + f"device entry snapshot failed for {device_entry.name}" + ) entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) assert entity_entries - assert ( - len({entity_entry.domain for entity_entry in entity_entries}) == 1 - ), "Please limit the loaded platforms to 1 platform." + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Please limit the loaded platforms to 1 platform." + ) translations = await async_get_translations(hass, "en", "entity", [DOMAIN]) unique_device_classes = [] @@ -233,22 +106,22 @@ async def snapshot_platform( if entity_entry.translation_key: key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" single_device_class_translation = False - if key not in translations and entity_entry.original_device_class: + if key not in translations: # No name translation if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) - assert ( - (key in translations) or single_device_class_translation - ), f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" - assert entity_entry == snapshot( - name=f"{entity_entry.entity_id}-entry" - ), f"entity entry snapshot failed for {entity_entry.entity_id}" + assert (key in translations) or single_device_class_translation, ( + f"No translation or non unique device_class for entity {entity_entry.unique_id}, expected {key}" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry"), ( + f"entity entry snapshot failed for {entity_entry.entity_id}" + ) if entity_entry.disabled_by is None: state = hass.states.get(entity_entry.entity_id) assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot( - name=f"{entity_entry.entity_id}-state" - ), f"state snapshot failed for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state"), ( + f"state snapshot failed for {entity_entry.entity_id}" + ) async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None: @@ -308,12 +181,6 @@ def _mocked_device( device_config.host = ip_address device.host = ip_address - if modules: - device.modules = { - module_name: MODULE_TO_MOCK_GEN[module_name](device) - for module_name in modules - } - device_features = {} if features: device_features = { @@ -331,14 +198,40 @@ def _mocked_device( ) device.features = device_features - for mod in device.modules.values(): - mod.get_feature.side_effect = device_features.get - mod.has_feature.side_effect = lambda id: id in device_features + # Add modules after features so modules can add any required features + if modules: + device.modules = { + module_name: MODULE_TO_MOCK_GEN[module_name](device) + for module_name in modules + } + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always so accept overrides. + module_features = { + mod_key if (mod_key := v.expected_module_key) else k: v + for k, v in device_features.items() + } + for mod in device.modules.values(): + # Some tests remove the feature from device_features to test missing + # features, so check the key is still present there. + mod.get_feature.side_effect = ( + lambda mod_id: mod_feat + if (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + else None + ) + mod.has_feature.side_effect = ( + lambda mod_id: (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + ) + + device.parent = None device.children = [] if children: for child in children: child.mac = mac + child.parent = device device.children = children device.device_type = device_type if device_type else DeviceType.Unknown if ( @@ -370,6 +263,7 @@ def _mocked_feature( unit=None, minimum_value=None, maximum_value=None, + expected_module_key=None, ) -> Feature: """Get a mocked feature. @@ -379,15 +273,30 @@ def _mocked_feature( feature.id = id feature.name = name or id.upper() feature.set_value = AsyncMock() - if not (fixture := FEATURES_FIXTURE.get(id)): - assert ( - require_fixture is False - ), f"No fixture defined for feature {id} and require_fixture is True" - assert ( - value is not UNDEFINED - ), f"Value must be provided if feature {id} not defined in features.json" + if fixture := FEATURES_FIXTURE.get(id): + # copy the fixture so tests do not interfere with each other + fixture = dict(fixture) + + if enum_type := fixture.get("enum_type"): + val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) + fixture["value"] = val + if timedelta_type := fixture.get("timedelta_type"): + fixture["value"] = timedelta(**{timedelta_type: fixture["value"]}) + + if unit_enum_type := fixture.get("unit_enum_type"): + val = FIXTURE_ENUM_TYPES[unit_enum_type](fixture["unit"]) + fixture["unit"] = val + + else: + assert require_fixture is False, ( + f"No fixture defined for feature {id} and require_fixture is True" + ) + assert value is not UNDEFINED, ( + f"Value must be provided if feature {id} not defined in features.json" + ) fixture = {"value": value, "category": "Primary", "type": "Sensor"} - elif value is not UNDEFINED: + + if value is not UNDEFINED: fixture["value"] = value feature.value = fixture["value"] @@ -407,6 +316,16 @@ def _mocked_feature( # select feature.choices = choices or fixture.get("choices") + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always. module_key indicates the key of the feature + # in the module. + feature.expected_module_key = ( + mod_key + if (mod_key := fixture.get("expected_module_key", expected_module_key)) + else None + ) + return feature @@ -456,12 +375,12 @@ def _mocked_light_effect_module(device) -> LightEffect: effect.effect_list = ["Off", "Effect1", "Effect2"] async def _set_effect(effect_name, *_, **__): - assert ( - effect_name in effect.effect_list - ), f"set_effect '{effect_name}' not in {effect.effect_list}" - assert device.modules[ - Module.Light - ], "Need a light module to test set_effect method" + assert effect_name in effect.effect_list, ( + f"set_effect '{effect_name}' not in {effect.effect_list}" + ) + assert device.modules[Module.Light], ( + "Need a light module to test set_effect method" + ) device.modules[Module.Light].state.light_on = True effect.effect = effect_name @@ -480,9 +399,23 @@ def _mocked_fan_module(effect) -> Fan: def _mocked_alarm_module(device): alarm = MagicMock(auto_spec=Alarm, name="Mocked alarm") alarm.active = False + alarm.alarm_sounds = "Foo", "Bar" alarm.play = AsyncMock() alarm.stop = AsyncMock() + device.features["alarm_volume"] = _mocked_feature( + "alarm_volume", + minimum_value=0, + maximum_value=3, + value=None, + ) + device.features["alarm_duration"] = _mocked_feature( + "alarm_duration", + minimum_value=0, + maximum_value=300, + value=None, + ) + return alarm @@ -497,6 +430,55 @@ def _mocked_camera_module(device): return camera +def _mocked_thermostat_module(device): + therm = MagicMock(auto_spec=Thermostat, name="Mocked thermostat") + therm.state = True + therm.temperature = 20.2 + therm.target_temperature = 22.2 + therm.mode = ThermostatState.Heating + therm.set_state = AsyncMock() + therm.set_target_temperature = AsyncMock() + + return therm + + +def _mocked_clean_module(device): + clean = MagicMock(auto_spec=Clean, name="Mocked clean") + + # methods + clean.start = AsyncMock() + clean.pause = AsyncMock() + clean.resume = AsyncMock() + clean.return_home = AsyncMock() + clean.set_fan_speed_preset = AsyncMock() + + # properties + clean.fan_speed_preset = "Max" + clean.error = ErrorCode.Ok + clean.battery = 100 + clean.status = Status.Charged + + # Need to manually create the fan speed preset feature, + # as we are going to read its choices through it + device.features["vacuum_fan_speed"] = _mocked_feature( + "vacuum_fan_speed", + type_=Feature.Type.Choice, + category=Feature.Category.Config, + choices=["Quiet", "Max"], + value="Max", + expected_module_key="fan_speed_preset", + ) + + return clean + + +def _mocked_speaker_module(device): + speaker = MagicMock(auto_spec=Speaker, name="Mocked speaker") + speaker.locate = AsyncMock() + + return speaker + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -565,6 +547,9 @@ MODULE_TO_MOCK_GEN = { Module.Fan: _mocked_fan_module, Module.Alarm: _mocked_alarm_module, Module.Camera: _mocked_camera_module, + Module.Thermostat: _mocked_thermostat_module, + Module.Clean: _mocked_clean_module, + Module.Speaker: _mocked_speaker_module, } diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index f1bbb80b80c..19cd5aa9acf 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -10,7 +10,8 @@ import pytest from homeassistant.components.tplink import DOMAIN from homeassistant.core import HomeAssistant -from . import ( +from . import _mocked_device +from .const import ( ALIAS_CAMERA, CREATE_ENTRY_DATA_AES_CAMERA, CREATE_ENTRY_DATA_LEGACY, @@ -26,7 +27,6 @@ from . import ( MAC_ADDRESS2, MAC_ADDRESS3, MODEL_CAMERA, - _mocked_device, ) from tests.common import MockConfigEntry @@ -115,8 +115,12 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_init() -> Generator[AsyncMock]: - """Override async_setup_entry.""" +def mock_init() -> Generator[dict[str, AsyncMock]]: + """Override async_setup and async_setup_entry. + + This fixture must be declared before the hass fixture to avoid errors + in the logs during teardown of the hass fixture which calls async_unload. + """ with patch.multiple( "homeassistant.components.tplink", async_setup=DEFAULT, diff --git a/tests/components/tplink/const.py b/tests/components/tplink/const.py new file mode 100644 index 00000000000..54aab1e2f3c --- /dev/null +++ b/tests/components/tplink/const.py @@ -0,0 +1,143 @@ +"""Constants for the tplink component tests.""" + +from kasa import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) + +from homeassistant.components.tplink import ( + CONF_AES_KEYS, + CONF_ALIAS, + CONF_CAMERA_CREDENTIALS, + CONF_CONNECTION_PARAMETERS, + CONF_CREDENTIALS_HASH, + CONF_HOST, + CONF_LIVE_VIEW, + CONF_MODEL, + CONF_USES_HTTP, + Credentials, +) + +MODULE = "homeassistant.components.tplink" +MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" +IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" +IP_ADDRESS3 = "127.0.0.3" +ALIAS = "My Bulb" +ALIAS_CAMERA = "My Camera" +MODEL = "HS100" +MODEL_CAMERA = "C210" +MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEVICE_ID = "123456789ABCDEFGH" +DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF" +DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") +MAC_ADDRESS2 = "11:22:33:44:55:66" +MAC_ADDRESS3 = "66:55:44:33:22:11" +DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}" +CREDENTIALS_HASH_LEGACY = "" +CONN_PARAMS_LEGACY = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor +) +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = { + k: v for k, v in DEVICE_CONFIG_LEGACY.to_dict().items() if k != "credentials" +} +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" +CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" +CONN_PARAMS_KLAP = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap +) +DEVICE_CONFIG_KLAP = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_KLAP, +) +CONN_PARAMS_AES = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes +) +_test_privkey = ( + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar" + "Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R" + "vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm" + "tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP" + "SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU" + "N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV" + "QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua" + "yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE" + "d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP" + "QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k" + "phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg" + "5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY=" +) +_test_pubkey = ( + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY" + "/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+" + "LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB" +) +AES_KEYS = {"private": _test_privkey, "public": _test_pubkey} +DEVICE_CONFIG_AES = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES, + aes_keys=AES_KEYS, +) +CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( + DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2 +) +DEVICE_CONFIG_AES_CAMERA = DeviceConfig( + IP_ADDRESS3, + credentials=CREDENTIALS, + connection_type=CONN_PARAMS_AES_CAMERA, +) + +DEVICE_CONFIG_DICT_KLAP = { + k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials" +} +DEVICE_CONFIG_DICT_AES = { + k: v for k, v in DEVICE_CONFIG_AES.to_dict().items() if k != "credentials" +} +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), + CONF_USES_HTTP: False, +} + +CREATE_ENTRY_DATA_KLAP = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), + CONF_USES_HTTP: True, +} +CREATE_ENTRY_DATA_AES = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), + CONF_USES_HTTP: True, + CONF_AES_KEYS: AES_KEYS, +} +CREATE_ENTRY_DATA_AES_CAMERA = { + CONF_HOST: IP_ADDRESS3, + CONF_ALIAS: ALIAS_CAMERA, + CONF_MODEL: MODEL_CAMERA, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(), + CONF_USES_HTTP: True, + CONF_LIVE_VIEW: True, + CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"}, +} +SMALLEST_VALID_JPEG = ( + "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" + "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" + "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" +) +SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 3d27e63b06a..81277ddd3ae 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -195,6 +195,11 @@ "type": "BinarySensor", "category": "Info" }, + "overloaded": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, "battery_low": { "value": false, "type": "BinarySensor", @@ -284,6 +289,12 @@ "minimum_value": -10, "maximum_value": 10 }, + "power_protection_threshold": { + "value": 100, + "type": "Number", + "category": "Config", + "minimum_value": 0 + }, "target_temperature": { "value": false, "type": "Number", @@ -330,6 +341,61 @@ "Connection 2" ] }, + "clean_time": { + "type": "Sensor", + "category": "Info", + "value": 12, + "timedelta_type": "minutes" + }, + "clean_area": { + "type": "Sensor", + "category": "Info", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "clean_progress": { + "type": "Sensor", + "category": "Info", + "value": 30, + "unit": "%" + }, + "total_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 120, + "timedelta_type": "minutes" + }, + "total_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 60, + "timedelta_type": "minutes" + }, + "last_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_timestamp": { + "type": "Sensor", + "category": "Debug", + "value": "2024-06-24 10:03:11.046643+01:00" + }, + "total_clean_count": { + "type": "Sensor", + "category": "Debug", + "value": 12 + }, "alarm_volume": { "value": "normal", "type": "Choice", @@ -370,5 +436,116 @@ "value": 10, "type": "Number", "category": "Config" + }, + "clean_count": { + "value": 1, + "type": "Number", + "category": "Config" + }, + "carpet_boost": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "vacuum_error": { + "value": 0, + "type": "Sensor", + "category": "Info", + "enum_type": "CleanErrorCode" + }, + "pair": { + "value": "", + "type": "Action", + "category": "Config" + }, + "unpair": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "main_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "side_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "sensor_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "filter_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "charging_contacts_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "main_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "main_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" } } diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index 4a1cfe5b411..17aa2c248e5 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -1,17 +1,18 @@ # serializer version: 1 -# name: test_states[binary_sensor.my_device_battery_low-entry] +# name: test_states[binary_sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.my_device_battery_low', + 'entity_id': 'binary_sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery low', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -39,6 +40,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -86,6 +88,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +136,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -166,6 +170,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +218,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,6 +307,54 @@ 'state': 'off', }) # --- +# name: test_states[binary_sensor.my_device_overloaded-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overloaded', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overloaded', + 'unique_id': '123456789ABCDEFGH_overloaded', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_overloaded-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'my_device Overloaded', + }), + 'context': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_states[binary_sensor.my_device_temperature_warning-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -307,6 +362,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -337,6 +393,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index de626cd5818..bb4e9f85d58 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_states[button.my_device_pair_new_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_pair_new_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pair new device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pair', + 'unique_id': '123456789ABCDEFGH_pair', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_pair_new_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pair new device', + }), + 'context': , + 'entity_id': 'button.my_device_pair_new_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[button.my_device_pan_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -91,6 +140,176 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_reset_charging_contacts_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_charging_contacts_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset charging contacts consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_reset', + 'unique_id': '123456789ABCDEFGH_charging_contacts_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_filter_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_filter_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': '123456789ABCDEFGH_filter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_main_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_main_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_reset', + 'unique_id': '123456789ABCDEFGH_main_brush_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_sensor_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_sensor_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset sensor consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_reset', + 'unique_id': '123456789ABCDEFGH_sensor_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_side_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_side_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_reset', + 'unique_id': '123456789ABCDEFGH_side_brush_reset', + 'unit_of_measurement': None, + }) +# --- # name: test_states[button.my_device_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -98,6 +317,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -131,6 +351,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -177,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -223,6 +445,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -269,6 +492,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,10 +532,45 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_unpair_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_unpair_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unpair device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unpair', + 'unique_id': '123456789ABCDEFGH_unpair', + 'unit_of_measurement': None, + }) +# --- # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index 4417395078a..e037c2c9e40 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index 6823c373b68..02492de92b9 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'min_temp': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -91,6 +93,6 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', - 'via_device_id': None, + 'via_device_id': , }) # --- diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index 1a7392dc63a..9c395dc2f21 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index df5ef71bf44..0415039a0ce 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -35,6 +36,62 @@ 'via_device_id': None, }) # --- +# name: test_states[number.my_device_clean_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_clean_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clean count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_count', + 'unique_id': '123456789ABCDEFGH_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_clean_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Clean count', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_clean_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_states[number.my_device_pan_degrees-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -47,6 +104,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -90,6 +148,62 @@ 'state': '10', }) # --- +# name: test_states[number.my_device_power_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_power_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power protection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_protection_threshold', + 'unique_id': '123456789ABCDEFGH_power_protection_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_power_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Power protection', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_power_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_states[number.my_device_smooth_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -102,6 +216,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +272,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +328,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -267,6 +384,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -322,6 +440,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index c851979f34c..e5191937ee9 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -64,6 +65,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -137,6 +139,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +197,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 739f02e51f0..72198e579a1 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,6 +43,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -75,6 +77,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -115,7 +118,7 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- -# name: test_states[sensor.my_device_battery_level-entry] +# name: test_states[sensor.my_device_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -124,12 +127,13 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -141,7 +145,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': 0, @@ -150,22 +154,239 @@ 'unit_of_measurement': '%', }) # --- -# name: test_states[sensor.my_device_battery_level-state] +# name: test_states[sensor.my_device_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'my_device Battery level', + 'friendly_name': 'my_device Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_device_battery_level', + 'entity_id': 'sensor.my_device_battery', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '85', }) # --- +# name: test_states[sensor.my_device_charging_contacts_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging contacts remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_remaining', + 'unique_id': '123456789ABCDEFGH_charging_contacts_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_charging_contacts_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging contacts used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_used', + 'unique_id': '123456789ABCDEFGH_charging_contacts_used', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_area', + 'unique_id': '123456789ABCDEFGH_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'area', + 'friendly_name': 'my_device Cleaning area', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning progress', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_progress', + 'unique_id': '123456789ABCDEFGH_clean_progress', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_time', + 'unique_id': '123456789ABCDEFGH_clean_time', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'my_device Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.00', + }) +# --- # name: test_states[sensor.my_device_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -175,6 +396,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -229,6 +451,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -281,6 +504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -307,6 +531,157 @@ 'unit_of_measurement': None, }) # --- +# name: test_states[sensor.my_device_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_error', + 'unique_id': '123456789ABCDEFGH_vacuum_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'my_device Error', + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_device_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_states[sensor.my_device_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_remaining', + 'unique_id': '123456789ABCDEFGH_filter_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_filter_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_used', + 'unique_id': '123456789ABCDEFGH_filter_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -316,6 +691,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +734,116 @@ 'state': '12', }) # --- +# name: test_states[sensor.my_device_last_clean_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_clean_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last clean start', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_timestamp', + 'unique_id': '123456789ABCDEFGH_last_clean_timestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last cleaned area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_area', + 'unique_id': '123456789ABCDEFGH_last_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last cleaned time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_time', + 'unique_id': '123456789ABCDEFGH_last_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_last_water_leak_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -365,6 +851,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +892,80 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- +# name: test_states[sensor.my_device_main_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_remaining', + 'unique_id': '123456789ABCDEFGH_main_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_main_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_used', + 'unique_id': '123456789ABCDEFGH_main_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_on_since-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -412,6 +973,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -445,6 +1007,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -471,6 +1034,154 @@ 'unit_of_measurement': '%', }) # --- +# name: test_states[sensor.my_device_sensor_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_remaining', + 'unique_id': '123456789ABCDEFGH_sensor_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_sensor_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_used', + 'unique_id': '123456789ABCDEFGH_sensor_used', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_remaining', + 'unique_id': '123456789ABCDEFGH_side_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_used', + 'unique_id': '123456789ABCDEFGH_side_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_signal_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -480,6 +1191,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +1241,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -562,6 +1275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -597,6 +1311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -632,6 +1347,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -686,6 +1402,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +1448,120 @@ 'state': '5.23', }) # --- +# name: test_states[sensor.my_device_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_area', + 'unique_id': '123456789ABCDEFGH_total_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_count', + 'unique_id': '123456789ABCDEFGH_total_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_time', + 'unique_id': '123456789ABCDEFGH_total_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_total_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -740,6 +1571,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -794,6 +1626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index b144288bd1c..7365e449707 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -40,8 +41,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'available_tones': tuple( + 'Foo', + 'Bar', + ), + }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,7 +69,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH', 'unit_of_measurement': None, @@ -71,8 +78,12 @@ # name: test_states[siren.hub-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'available_tones': tuple( + 'Foo', + 'Bar', + ), 'friendly_name': 'hub', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'siren.hub', diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 7adda900c02..bd89da8e841 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,6 +43,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -88,6 +90,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -134,6 +137,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -180,6 +184,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -219,6 +224,53 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_carpet_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_carpet_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carpet boost', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_boost', + 'unique_id': '123456789ABCDEFGH_carpet_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_carpet_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Carpet boost', + }), + 'context': , + 'entity_id': 'switch.my_device_carpet_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -226,6 +278,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -272,6 +325,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -318,6 +372,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -364,6 +419,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -410,6 +466,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,6 +513,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -502,6 +560,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +607,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..e010c9545d1 --- /dev/null +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_states[my_vacuum-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'model_id': None, + 'name': 'my_vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'quiet', + 'max', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.my_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vacuum', + 'unique_id': '123456789ABCDEFGH-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-charging-100', + 'battery_level': 100, + 'fan_speed': 'max', + 'fan_speed_list': list([ + 'quiet', + 'max', + ]), + 'friendly_name': 'my_vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.my_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tplink/test_binary_sensor.py b/tests/components/tplink/test_binary_sensor.py index e2b9cd08d13..b487fa51baf 100644 --- a/tests/components/tplink/test_binary_sensor.py +++ b/tests/components/tplink/test_binary_sensor.py @@ -4,18 +4,14 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.binary_sensor import BINARY_SENSOR_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -24,6 +20,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -47,7 +44,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor states.""" features = {description.key for description in BINARY_SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -68,7 +65,7 @@ async def test_binary_sensor( entity_registry: er.EntityRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor unique ids.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -77,7 +74,7 @@ async def test_binary_sensor( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -93,7 +90,7 @@ async def test_binary_sensor_children( device_registry: dr.DeviceRegistry, mocked_feature_binary_sensor: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test binary sensor children.""" mocked_feature = mocked_feature_binary_sensor already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -105,7 +102,7 @@ async def test_binary_sensor_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "binary_sensor.my_plug_overheated" diff --git a/tests/components/tplink/test_button.py b/tests/components/tplink/test_button.py index a3eb8950336..c36d08337a7 100644 --- a/tests/components/tplink/test_button.py +++ b/tests/components/tplink/test_button.py @@ -4,7 +4,6 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.tplink.button import BUTTON_DESCRIPTIONS from homeassistant.components.tplink.const import DOMAIN @@ -16,11 +15,8 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -30,6 +26,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -83,7 +80,7 @@ def create_deprecated_child_button_entities( @pytest.fixture def mocked_feature_button() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink button feature.""" return _mocked_feature( "test_alarm", value="", @@ -101,7 +98,7 @@ async def test_states( snapshot: SnapshotAssertion, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button states.""" features = {description.key for description in BUTTON_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -118,14 +115,15 @@ async def test_states( async def test_button( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button unique ids.""" mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -139,11 +137,12 @@ async def test_button_children( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, create_deprecated_child_button_entities, ) -> None: - """Test a sensor unique ids.""" + """Test button children.""" mocked_feature = mocked_feature_button plug = _mocked_device( alias="my_device", @@ -151,7 +150,7 @@ async def test_button_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -173,6 +172,7 @@ async def test_button_children( async def test_button_press( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mocked_feature_button: Feature, create_deprecated_button_entities, ) -> None: @@ -180,7 +180,7 @@ async def test_button_press( mocked_feature = mocked_feature_button plug = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() entity_id = "button.my_device_test_alarm" @@ -213,7 +213,7 @@ async def test_button_not_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert not entity_registry.async_get(entity_id) @@ -265,7 +265,7 @@ async def test_button_exists_with_deprecation( mocked_feature = mocked_feature_button dev = _mocked_device(alias="my_device", features=[mocked_feature]) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity = entity_registry.async_get(entity_id) diff --git a/tests/components/tplink/test_camera.py b/tests/components/tplink/test_camera.py index aa83ae659fb..4b062c4d0b2 100644 --- a/tests/components/tplink/test_camera.py +++ b/tests/components/tplink/test_camera.py @@ -11,6 +11,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import stream from homeassistant.components.camera import ( + DOMAIN as CAMERA_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, CameraEntityFeature, StreamType, async_get_image, @@ -23,15 +26,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - DEVICE_ID, - IP_ADDRESS3, - MAC_ADDRESS3, - SMALLEST_VALID_JPEG_BYTES, - _mocked_device, - setup_platform_for_device, - snapshot_platform, -) +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID, IP_ADDRESS3, MAC_ADDRESS3, SMALLEST_VALID_JPEG_BYTES from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -44,7 +40,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test states.""" + """Test camera states.""" mock_camera_config_entry.add_to_hass(hass) mock_device = _mocked_device( @@ -73,6 +69,7 @@ async def test_camera_unique_id( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test camera unique id.""" mock_device = _mocked_device( @@ -92,14 +89,13 @@ async def test_camera_unique_id( ) assert device_entries entity_id = "camera.my_camera_live_view" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == f"{DEVICE_ID}-live_view" async def test_handle_mjpeg_stream( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test handle_async_mjpeg_stream.""" mock_device = _mocked_device( @@ -126,9 +122,8 @@ async def test_handle_mjpeg_stream( async def test_handle_mjpeg_stream_not_supported( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: - """Test handle_async_mjpeg_stream.""" + """Test no stream if stream_rtsp_url is None after creation.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -137,17 +132,17 @@ async def test_handle_mjpeg_stream_not_supported( ) mock_camera = mock_device.modules[Module.Camera] - mock_camera.stream_rtsp_url.return_value = None + mock_camera.stream_rtsp_url.side_effect = ("foo", None) await setup_platform_for_device( hass, mock_camera_config_entry, Platform.CAMERA, mock_device ) mock_request = make_mocked_request("GET", "/", headers={"token": "x"}) - stream = await async_get_mjpeg_stream( + mjpeg_stream = await async_get_mjpeg_stream( hass, mock_request, "camera.my_camera_live_view" ) - assert stream is None + assert mjpeg_stream is None async def test_camera_image( @@ -216,7 +211,7 @@ async def test_no_camera_image_when_streaming( mock_camera_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test async_get_image.""" + """Test no camera image when streaming.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -272,9 +267,8 @@ async def test_no_camera_image_when_streaming( async def test_no_concurrent_camera_image( hass: HomeAssistant, mock_camera_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: - """Test async_get_image.""" + """Test async_get_image doesn't make concurrent requests.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -321,7 +315,7 @@ async def test_camera_image_auth_error( mock_connect: AsyncMock, mock_discovery: AsyncMock, ) -> None: - """Test async_get_image.""" + """Test async_get_image auth error.""" mock_device = _mocked_device( modules=[Module.Camera], alias="my_camera", @@ -367,7 +361,7 @@ async def test_camera_stream_source( mock_camera_config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: - """Test async_get_image. + """Test camera stream source. This test would fail if the integration didn't properly put stream in the dependencies. @@ -444,16 +438,16 @@ async def test_camera_turn_on_off( assert state is not None await hass.services.async_call( - "camera", - "turn_on", + CAMERA_DOMAIN, + SERVICE_TURN_ON, {"entity_id": "camera.my_camera_live_view"}, blocking=True, ) mock_camera.set_state.assert_called_with(True) await hass.services.async_call( - "camera", - "turn_off", + CAMERA_DOMAIN, + SERVICE_TURN_OFF, {"entity_id": "camera.my_camera_live_view"}, blocking=True, ) diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index 3a54048e1d6..adcca24886b 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -2,7 +2,7 @@ from datetime import timedelta -from kasa import Device, Feature +from kasa import Device, Feature, Module from kasa.smart.modules.temperaturecontrol import ThermostatState import pytest from syrupy.assertion import SnapshotAssertion @@ -27,12 +27,12 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from . import ( - DEVICE_ID, _mocked_device, _mocked_feature, setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -41,35 +41,28 @@ ENTITY_ID = "climate.thermostat" @pytest.fixture async def mocked_hub(hass: HomeAssistant) -> Device: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink hub.""" features = [ _mocked_feature( - "temperature", value=20.2, category=Feature.Category.Primary, unit="celsius" - ), - _mocked_feature( - "target_temperature", - value=22.2, + "temperature", type_=Feature.Type.Number, category=Feature.Category.Primary, unit="celsius", ), _mocked_feature( - "state", - value=True, - type_=Feature.Type.Switch, - category=Feature.Category.Primary, - ), - _mocked_feature( - "thermostat_mode", - value=ThermostatState.Heating, - type_=Feature.Type.Choice, + "target_temperature", + type_=Feature.Type.Number, category=Feature.Category.Primary, + unit="celsius", ), ] thermostat = _mocked_device( - alias="thermostat", features=features, device_type=Device.Type.Thermostat + alias="thermostat", + features=features, + modules=[Module.Thermostat], + device_type=Device.Type.Thermostat, ) return _mocked_device( @@ -121,7 +114,9 @@ async def test_set_temperature( ) -> None: """Test that set_temperature service calls the setter.""" mocked_thermostat = mocked_hub.children[0] - mocked_thermostat.features["target_temperature"].minimum_value = 0 + + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await setup_platform_for_device( hass, mock_config_entry, Platform.CLIMATE, mocked_hub @@ -133,8 +128,8 @@ async def test_set_temperature( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10}, blocking=True, ) - target_temp_feature = mocked_thermostat.features["target_temperature"] - target_temp_feature.set_value.assert_called_with(10) + + therm_module.set_target_temperature.assert_called_with(10) async def test_set_hvac_mode( @@ -146,8 +141,8 @@ async def test_set_hvac_mode( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["state"] - assert mocked_state is not None + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await hass.services.async_call( CLIMATE_DOMAIN, @@ -156,7 +151,7 @@ async def test_set_hvac_mode( blocking=True, ) - mocked_state.set_value.assert_called_with(False) + therm_module.set_state.assert_called_with(False) await hass.services.async_call( CLIMATE_DOMAIN, @@ -164,9 +159,10 @@ async def test_set_hvac_mode( {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mocked_state.set_value.assert_called_with(True) + therm_module.set_state.assert_called_with(True) - with pytest.raises(ServiceValidationError): + msg = "Tried to set unsupported mode: dry" + with pytest.raises(ServiceValidationError, match=msg): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -184,7 +180,8 @@ async def test_turn_on_and_off( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["state"] + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module await hass.services.async_call( CLIMATE_DOMAIN, @@ -193,7 +190,7 @@ async def test_turn_on_and_off( blocking=True, ) - mocked_state.set_value.assert_called_with(False) + therm_module.set_state.assert_called_with(False) await hass.services.async_call( CLIMATE_DOMAIN, @@ -202,7 +199,7 @@ async def test_turn_on_and_off( blocking=True, ) - mocked_state.set_value.assert_called_with(True) + therm_module.set_state.assert_called_with(True) async def test_unknown_mode( @@ -217,11 +214,31 @@ async def test_unknown_mode( ) mocked_thermostat = mocked_hub.children[0] - mocked_state = mocked_thermostat.features["thermostat_mode"] - mocked_state.value = ThermostatState.Unknown + therm_module = mocked_thermostat.modules.get(Module.Thermostat) + assert therm_module + + therm_module.mode = ThermostatState.Unknown async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert "Unknown thermostat state, defaulting to OFF" in caplog.text + + +async def test_missing_feature_attributes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a module missing the min/max and unit feature logs an error.""" + mocked_thermostat = mocked_hub.children[0] + mocked_thermostat.features.pop("target_temperature") + mocked_thermostat.features.pop("temperature") + + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + assert "Unable to get min/max target temperature" in caplog.text + assert "Unable to get correct temperature unit" in caplog.text diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 14f1260e2ec..35fd4f418de 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -7,7 +7,7 @@ from kasa import Module, TimeoutError import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, stream +from homeassistant.components import stream from homeassistant.components.tplink import ( DOMAIN, AuthenticationError, @@ -36,8 +36,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import ( +from . import _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery +from .conftest import override_side_effect +from .const import ( AES_KEYS, ALIAS, ALIAS_CAMERA, @@ -67,12 +70,7 @@ from . import ( MODEL_CAMERA, MODULE, SMALLEST_VALID_JPEG_BYTES, - _mocked_device, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) -from .conftest import override_side_effect from tests.common import MockConfigEntry @@ -168,8 +166,11 @@ async def test_discovery( assert result2["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_init") async def test_discovery_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -228,8 +229,11 @@ async def test_discovery_camera( assert result["context"]["unique_id"] == MAC_ADDRESS3 +@pytest.mark.usefixtures("mock_init") async def test_discovery_pick_device_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -293,8 +297,11 @@ async def test_discovery_pick_device_camera( assert result["context"]["unique_id"] == MAC_ADDRESS3 +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery.""" mock_device = _mocked_device( @@ -336,8 +343,11 @@ async def test_discovery_auth( assert result2["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_camera( - hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, ) -> None: """Test authenticated discovery for camera with stream.""" mock_device = _mocked_device( @@ -407,13 +417,13 @@ async def test_discovery_auth_camera( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_discovery_auth_errors( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test handling of discovery authentication errors. @@ -465,10 +475,10 @@ async def test_discovery_auth_errors( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -514,10 +524,10 @@ async def test_discovery_new_credentials( assert result3["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_new_credentials_invalid( hass: HomeAssistant, mock_connect: AsyncMock, - mock_init, ) -> None: """Test setting up discovery with new invalid credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS] @@ -977,11 +987,11 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: assert result["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_manual_auth( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test manually setup.""" result = await hass.config_entries.flow.async_init( @@ -1083,14 +1093,14 @@ async def test_manual_auth_camera( ], ids=["invalid-auth", "unknown-error"], ) +@pytest.mark.usefixtures("mock_init") async def test_manual_auth_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test manually setup auth errors.""" result = await hass.config_entries.flow.async_init( @@ -1150,16 +1160,15 @@ async def test_manual_port_override( hass: HomeAssistant, mock_connect: AsyncMock, mock_discovery: AsyncMock, - host_str, - host, - port, + host_str: str, + host: str, + port: int, ) -> None: """Test manually setup.""" config = DeviceConfig( host, credentials=None, port_override=port, - uses_http=True, connection_type=CONN_PARAMS_KLAP, ) mock_device = _mocked_device( @@ -1282,7 +1291,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1296,7 +1305,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -1312,7 +1321,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -1326,7 +1335,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -1342,7 +1351,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ], ) async def test_discovered_by_dhcp_or_discovery( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we can setup when discovered from dhcp or discovery.""" @@ -1380,7 +1389,7 @@ async def test_discovered_by_dhcp_or_discovery( [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ), @@ -1396,7 +1405,7 @@ async def test_discovered_by_dhcp_or_discovery( ], ) async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( - hass: HomeAssistant, source, data + hass: HomeAssistant, source: str, data: dict ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" @@ -1419,7 +1428,7 @@ async def test_integration_discovery_with_ip_change( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test reauth flow.""" + """Test integration updates ip address from discovery.""" mock_config_entry.add_to_hass(hass) with ( patch("homeassistant.components.tplink.Discover.discover", return_value={}), @@ -1481,7 +1490,6 @@ async def test_integration_discovery_with_ip_change( # Check that init set the new host correctly before calling connect assert config.host == IP_ADDRESS config.host = IP_ADDRESS2 - config.uses_http = False # Not passed in to new config class config.http_client = "Foo" mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1568,7 +1576,6 @@ async def test_integration_discovery_with_connection_change( assert mock_config_entry.state is ConfigEntryState.LOADED config.host = IP_ADDRESS2 - config.uses_http = False # Not passed in to new config class config.http_client = "Foo" config.aes_keys = AES_KEYS mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1597,7 +1604,7 @@ async def test_dhcp_discovery_with_ip_change( discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1622,7 +1629,7 @@ async def test_dhcp_discovery_discover_fail( discovery_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS2, macaddress=DHCP_FORMATTED_MAC_ADDRESS, hostname=ALIAS ), ) @@ -1670,7 +1677,7 @@ async def test_reauth_camera( mock_discovery: AsyncMock, mock_connect: AsyncMock, ) -> None: - """Test async_get_image.""" + """Test reauth flow on invalid camera credentials.""" mock_device = mock_connect["mock_devices"][IP_ADDRESS3] mock_camera_config_entry.add_to_hass(hass) mock_camera_config_entry.async_start_reauth( @@ -1762,7 +1769,7 @@ async def test_reauth_try_connect_all_fail( override_side_effect(mock_discovery["discover_single"], TimeoutError), override_side_effect(mock_discovery["try_connect_all"], lambda *_, **__: None), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_USERNAME: "fake_username", @@ -1774,7 +1781,23 @@ async def test_reauth_try_connect_all_fail( IP_ADDRESS, credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() - assert result2["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} + + mock_discovery["try_connect_all"].reset_mock() + with ( + override_side_effect(mock_discovery["discover_single"], TimeoutError), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["try_connect_all"].assert_called_once() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_update_with_encryption_change( @@ -1821,7 +1844,6 @@ async def test_reauth_update_with_encryption_change( connection_type=Device.ConnectionParameters( Device.Family.SmartTapoPlug, Device.EncryptionType.Klap ), - uses_http=True, ) mock_device = _mocked_device( alias="my_device", @@ -2025,9 +2047,9 @@ async def test_reauth_errors( mock_added_config_entry: MockConfigEntry, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - errors_msg, - error_placement, + error_type: Exception, + errors_msg: str, + error_placement: str, ) -> None: """Test reauth errors.""" mock_added_config_entry.async_start_reauth(hass) @@ -2089,8 +2111,8 @@ async def test_pick_device_errors( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - error_type, - expected_flow, + error_type: type[Exception], + expected_flow: FlowResultType, ) -> None: """Test errors on pick_device.""" result = await hass.config_entries.flow.async_init( @@ -2127,11 +2149,11 @@ async def test_pick_device_errors( assert result4["context"]["unique_id"] == MAC_ADDRESS +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -2153,11 +2175,11 @@ async def test_discovery_timeout_try_connect_all( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_needs_creds( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( @@ -2191,11 +2213,11 @@ async def test_discovery_timeout_try_connect_all_needs_creds( assert mock_connect["connect"].call_count == 1 +@pytest.mark.usefixtures("mock_init") async def test_discovery_timeout_try_connect_all_fail( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - mock_init, ) -> None: """Test discovery tries legacy connect on timeout.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/tplink/test_fan.py b/tests/components/tplink/test_fan.py index deba33abfa5..13a768f683c 100644 --- a/tests/components/tplink/test_fan.py +++ b/tests/components/tplink/test_fan.py @@ -2,8 +2,7 @@ from __future__ import annotations -from datetime import timedelta - +from freezegun.api import FrozenDateTimeFactory from kasa import Device, Module from syrupy.assertion import SnapshotAssertion @@ -11,13 +10,15 @@ from homeassistant.components.fan import ( ATTR_PERCENTAGE, DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util -from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform +from . import _mocked_device, setup_platform_for_device, snapshot_platform +from .const import DEVICE_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -56,6 +57,7 @@ async def test_fan_unique_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test a fan unique id.""" fan = _mocked_device(modules=[Module.Fan], alias="my_fan") @@ -66,12 +68,16 @@ async def test_fan_unique_id( ) assert device_entries entity_id = "fan.my_fan" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == DEVICE_ID -async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: - """Test a color fan and that all transitions are correctly passed.""" +async def test_fan( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fan functionality.""" device = _mocked_device(modules=[Module.Fan], alias="my_fan") fan = device.modules[Module.Fan] fan.fan_speed_level = 0 @@ -83,26 +89,29 @@ async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> N assert state.state == "off" await hass.services.async_call( - FAN_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(4) fan.set_fan_speed_level.reset_mock() fan.fan_speed_level = 4 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + + freezer.tick(10) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == "on" await hass.services.async_call( - FAN_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) fan.set_fan_speed_level.assert_called_once_with(0) fan.set_fan_speed_level.reset_mock() await hass.services.async_call( FAN_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, blocking=True, ) diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 8dad8881b9b..972ca73c45c 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -8,10 +8,18 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module +from kasa import ( + AuthenticationError, + Device, + DeviceConfig, + DeviceType, + Feature, + KasaException, + Module, +) +from kasa.iot import IotStrip import pytest -from homeassistant import setup from homeassistant.components import tplink from homeassistant.components.tplink.const import ( CONF_AES_KEYS, @@ -38,6 +46,14 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + _mocked_device, + _mocked_feature, + _patch_connect, + _patch_discovery, + _patch_single_discovery, +) +from .conftest import override_side_effect +from .const import ( ALIAS, CREATE_ENTRY_DATA_AES, CREATE_ENTRY_DATA_KLAP, @@ -51,15 +67,11 @@ from . import ( DEVICE_ID, DEVICE_ID_MAC, IP_ADDRESS, + IP_ADDRESS3, MAC_ADDRESS, + MAC_ADDRESS3, MODEL, - _mocked_device, - _mocked_feature, - _patch_connect, - _patch_discovery, - _patch_single_discovery, ) -from .conftest import override_side_effect from tests.common import MockConfigEntry, async_fire_time_changed @@ -98,7 +110,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(already_migrated_config_entry.entry_id) @@ -117,7 +129,7 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: _patch_single_discovery(no_device=True), _patch_connect(no_device=True), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -151,7 +163,7 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( _patch_single_discovery(device=dimmer), _patch_connect(device=dimmer), ): - await setup.async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) migrated_dimmer_entity_reg = entity_registry.async_get_or_create( @@ -175,7 +187,7 @@ async def test_config_entry_wrong_mac_Address( ) already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(), _patch_single_discovery(), _patch_connect(): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() assert already_migrated_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -234,7 +246,6 @@ async def test_config_entry_with_stored_credentials( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED config = DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()) - config.uses_http = False config.http_client = "Foo" assert config.credentials != stored_credentials config.credentials = stored_credentials @@ -309,7 +320,7 @@ async def test_plug_auth_fails(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) device = _mocked_device(alias="my_plug", features=["state"]) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -355,7 +366,7 @@ async def test_update_attrs_fails_in_init( type(light_module).color_temp = p light.__str__ = lambda _: "MockLight" with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -363,7 +374,7 @@ async def test_update_attrs_fails_in_init( assert entity state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - assert "Unable to read data for MockLight None:" in caplog.text + assert f"Unable to read data for MockLight {entity_id}:" in caplog.text async def test_update_attrs_fails_on_update( @@ -388,7 +399,7 @@ async def test_update_attrs_fails_on_update( light_module = light.modules[Module.Light] with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -434,7 +445,7 @@ async def test_feature_no_category( ) dev.features["led"].category = Feature.Category.Unset with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug_led" @@ -501,7 +512,7 @@ async def test_unlink_devices( # Generate list of test identifiers test_identifiers = [ - (domain, f"{device_id}{"" if i == 0 else f"_000{i}"}") + (domain, f"{device_id}{'' if i == 0 else f'_000{i}'}") for i in range(id_count) for domain in domains ] @@ -750,7 +761,6 @@ async def test_credentials_hash_auth_error( expected_config = DeviceConfig.from_dict( {**DEVICE_CONFIG_DICT_KLAP, "credentials_hash": "theHash"} ) - expected_config.uses_http = False expected_config.http_client = "Foo" connect_mock.assert_called_with(config=expected_config) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -782,13 +792,20 @@ async def test_migrate_remove_device_config( As async_setup_entry will succeed the hash on the parent is updated from the device. """ + old_device_config = { + k: v for k, v in device_config.to_dict().items() if k != "credentials" + } + device_config_dict = { + **old_device_config, + "uses_http": device_config.connection_type.encryption_type + is not Device.EncryptionType.Xor, + } + OLD_CREATE_ENTRY_DATA = { CONF_HOST: expected_entry_data[CONF_HOST], CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, - CONF_DEVICE_CONFIG: { - k: v for k, v in device_config.to_dict().items() if k != "credentials" - }, + CONF_DEVICE_CONFIG: device_config_dict, } entry = MockConfigEntry( @@ -825,3 +842,390 @@ async def test_migrate_remove_device_config( assert entry.data == expected_entry_data assert "Migration to version 1.5 complete" in caplog.text + + +@pytest.mark.parametrize( + ("parent_device_type"), + [ + (Device), + (IotStrip), + ], +) +@pytest.mark.parametrize( + ("platform", "feature_id", "translated_name"), + [ + pytest.param("switch", "led", "led", id="switch"), + pytest.param( + "sensor", "current_consumption", "current_consumption", id="sensor" + ), + pytest.param("binary_sensor", "overheated", "overheated", id="binary_sensor"), + pytest.param("number", "smooth_transition_on", "smooth_on", id="number"), + pytest.param("select", "light_preset", "light_preset", id="select"), + pytest.param("button", "reboot", "restart", id="button"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_feature_device_addition_and_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + feature_id: str, + translated_name: str, + parent_device_type: type, +) -> None: + """Test for automatic device with features addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=[feature_id], + device_type=DeviceType.StripSocket, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=[feature_id], + device_type=DeviceType.Hub, + spec=parent_device_type, + device_id="hub_parent", + ) + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + entity_id = f"{platform}.child_2_{translated_name}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + entity_id = f"{platform}.child_{child_id}_{translated_name}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + +@pytest.mark.parametrize( + ("platform", "modules", "features", "translated_name", "child_device_type"), + [ + pytest.param( + "camera", [Module.Camera], [], "live_view", DeviceType.Camera, id="camera" + ), + pytest.param("fan", [Module.Fan], [], None, DeviceType.Fan, id="fan"), + pytest.param("siren", [Module.Alarm], [], None, DeviceType.Camera, id="siren"), + pytest.param("light", [Module.Light], [], None, DeviceType.Camera, id="light"), + pytest.param( + "light", + [Module.Light, Module.LightEffect], + [], + None, + DeviceType.Camera, + id="light_effect", + ), + pytest.param( + "climate", + [Module.Thermostat], + ["temperature", "target_temperature"], + None, + DeviceType.Thermostat, + id="climate", + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_automatic_module_device_addition_and_removal( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, + platform: str, + modules: list[str], + features: list[str], + translated_name: str | None, + child_device_type: DeviceType, +) -> None: + """Test for automatic device with modules addition and removal.""" + + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + modules=modules, + features=features, + device_type=child_device_type, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=["ssid"], + device_type=DeviceType.Hub, + device_id="hub_parent", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + # Set the parent property for the dynamic children as mock_device only sets + # it on initialization + for child in children.values(): + child.parent = mock_device + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_camera_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_camera_config_entry.entry_id) + await hass.async_block_till_done() + + for child_id in (1, 2): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + parent_device = device_registry.async_get_device( + identifiers={(DOMAIN, "hub_parent")} + ) + assert parent_device + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Remove one of the devices + mock_device.children = [children["child1"]] + freezer.tick(5) + async_fire_time_changed(hass) + + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_2{sub_id}" + state = hass.states.get(entity_id) + assert state is None + assert entity_registry.async_get(entity_id) is None + + assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None + + # Re-dd the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + # Add child devices + mock_device.children = [children["child1"], children["child3"], children["child4"]] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 3, 4): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child3", "child4"): + assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + + # Add the previously removed child device + mock_device.children = [ + children["child1"], + children["child2"], + children["child3"], + children["child4"], + ] + freezer.tick(5) + async_fire_time_changed(hass) + + for child_id in (1, 2, 3, 4): + sub_id = f"_{translated_name}" if translated_name else "" + entity_id = f"{platform}.child_{child_id}{sub_id}" + state = hass.states.get(entity_id) + assert state + assert entity_registry.async_get(entity_id) + + for device_id in ("child1", "child2", "child3", "child4"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) + assert device_entry + assert device_entry.via_device_id == parent_device.id + + +async def test_automatic_device_addition_does_not_remove_disabled_default( + hass: HomeAssistant, + mock_camera_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for automatic device addition does not remove disabled default entities.""" + + features = ["ssid", "signal_level"] + children = { + f"child{index}": _mocked_device( + alias=f"child {index}", + features=features, + device_id=f"child{index}", + ) + for index in range(1, 5) + } + + mock_device = _mocked_device( + alias="hub", + children=[children["child1"], children["child2"]], + features=features, + device_type=DeviceType.Hub, + device_id="hub_parent", + ip_address=IP_ADDRESS3, + mac=MAC_ADDRESS3, + ) + # Set the parent property for the dynamic children as mock_device only sets + # it on initialization + for child in children.values(): + child.parent = mock_device + + with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device): + mock_camera_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_camera_config_entry.entry_id) + await hass.async_block_till_done() + + def check_entities(entity_id_device): + entity_id = f"sensor.{entity_id_device}_signal_level" + state = hass.states.get(entity_id) + assert state + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + assert reg_ent.disabled is False + + entity_id = f"sensor.{entity_id_device}_ssid" + state = hass.states.get(entity_id) + assert state is None + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + assert reg_ent.disabled is True + assert reg_ent.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + check_entities("hub") + for child_id in (1, 2): + check_entities(f"child_{child_id}") + + # Add child devices + mock_device.children = [children["child1"], children["child2"], children["child3"]] + freezer.tick(5) + async_fire_time_changed(hass) + + check_entities("hub") + for child_id in (1, 2, 3): + check_entities(f"child_{child_id}") diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 6549711b7fc..7ae21fb4a26 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import re from unittest.mock import MagicMock, PropertyMock from freezegun.api import FrozenDateTimeFactory @@ -19,6 +20,11 @@ from kasa.iot import IotDevice import pytest from homeassistant.components import tplink +from homeassistant.components.homeassistant.scene import ( + CONF_SCENE_ID, + CONF_SNAPSHOT, + SERVICE_CREATE, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -34,8 +40,15 @@ from homeassistant.components.light import ( ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, EFFECT_OFF, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.light import ( + SERVICE_RANDOM_EFFECT, + SERVICE_SEQUENCE_EFFECT, +) from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( ATTR_ENTITY_ID, @@ -48,17 +61,16 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _patch_connect, _patch_discovery, _patch_single_discovery, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -83,7 +95,7 @@ async def test_light_unique_id( light = _mocked_device(modules=[Module.Light], alias="my_light") light.device_type = device_type with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -93,8 +105,11 @@ async def test_light_unique_id( ) -async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: - """Test a light unique id.""" +async def test_legacy_dimmer_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test dimmer unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -108,16 +123,16 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: light.device_type = DeviceType.Dimmer with _patch_discovery(device=light), _patch_connect(device=light): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" - entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.parametrize( - ("device", "transition"), + ("device", "extra_data", "expected_transition"), [ ( _mocked_device( @@ -130,7 +145,138 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: ), ], ), - 2.0, + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, + ), + ( + _mocked_device( + modules=[Module.Light], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {}, + None, + ), + ], +) +async def test_color_light( + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, +) -> None: + """Test a color light and that all transitions are correctly passed.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + light = device.modules[Module.Light] + + # Setting color_temp to None emulates a device without color temp + light.color_temp = None + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} + BASE_PAYLOAD |= extra_data + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + assert attributes.get(ATTR_EFFECT) is None + + assert attributes[ATTR_COLOR_MODE] == "hs" + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_HS_COLOR] == (10, 30) + assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) + assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=False, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) + light.set_state.assert_called_once_with( + LightState(light_on=True, transition=expected_transition) + ) + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=expected_transition) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with( + 6666, brightness=None, transition=expected_transition + ) + light.set_color_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) + light.set_hsv.reset_mock() + + +@pytest.mark.parametrize( + ("device", "extra_data", "expected_transition"), + [ + ( + _mocked_device( + modules=[Module.Light, Module.LightEffect], + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ), + {ATTR_TRANSITION: 2.0}, + 2.0 * 1_000, ), ( _mocked_device( @@ -143,12 +289,16 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: ), ], ), + {}, None, ), ], ) -async def test_color_light( - hass: HomeAssistant, device: MagicMock, transition: float | None +async def test_color_light_with_active_effect( + hass: HomeAssistant, + device: MagicMock, + extra_data: dict, + expected_transition: float | None, ) -> None: """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( @@ -157,93 +307,84 @@ async def test_color_light( already_migrated_config_entry.add_to_hass(hass) light = device.modules[Module.Light] - # Setting color_temp to None emulates a device with active effects - light.color_temp = None - with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_bulb" - KASA_TRANSITION_VALUE = transition * 1_000 if transition is not None else None BASE_PAYLOAD = {ATTR_ENTITY_ID: entity_id} - if transition: - BASE_PAYLOAD[ATTR_TRANSITION] = transition + BASE_PAYLOAD |= extra_data state = hass.states.get(entity_id) assert state.state == "on" attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + # If effect is active, only the brightness can be controlled - if attributes.get(ATTR_EFFECT) is not None: - assert attributes[ATTR_COLOR_MODE] == "brightness" - else: - assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 - assert attributes[ATTR_HS_COLOR] == (10, 30) - assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) - assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) + assert attributes.get(ATTR_EFFECT) is not None + assert attributes[ATTR_COLOR_MODE] == "brightness" await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", BASE_PAYLOAD, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, BASE_PAYLOAD, blocking=True ) light.set_state.assert_called_once_with( - LightState(light_on=False, transition=KASA_TRANSITION_VALUE) + LightState(light_on=False, transition=expected_transition) ) light.set_state.reset_mock() - await hass.services.async_call(LIGHT_DOMAIN, "turn_on", BASE_PAYLOAD, blocking=True) + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, BASE_PAYLOAD, blocking=True + ) light.set_state.assert_called_once_with( - LightState(light_on=True, transition=KASA_TRANSITION_VALUE) + LightState(light_on=True, transition=expected_transition) ) light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, blocking=True, ) - light.set_brightness.assert_called_with(39, transition=KASA_TRANSITION_VALUE) + light.set_brightness.assert_called_with(39, transition=expected_transition) light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) light.set_color_temp.assert_called_with( - 6666, brightness=None, transition=KASA_TRANSITION_VALUE + 6666, brightness=None, transition=expected_transition ) light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - light.set_hsv.assert_called_with(10, 30, None, transition=KASA_TRANSITION_VALUE) + light.set_hsv.assert_called_with(10, 30, None, transition=expected_transition) light.set_hsv.reset_mock() async def test_color_light_no_temp(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a color light with no color temp.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -258,7 +399,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: type(light).color_temp = PropertyMock(side_effect=Exception) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -274,20 +415,20 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -296,7 +437,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) @@ -304,51 +445,28 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: light.set_hsv.reset_mock() -@pytest.mark.parametrize( - ("device", "is_color"), - [ - ( - _mocked_device( - modules=[Module.Light], - alias="my_light", - features=[ - _mocked_feature("brightness", value=50), - _mocked_feature("hsv", value=(10, 30, 5)), - _mocked_feature( - "color_temp", value=4000, minimum_value=4000, maximum_value=9000 - ), - ], +async def test_color_temp_light_color(hass: HomeAssistant) -> None: + """Test a color temp light with color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature("hsv", value=(10, 30, 5)), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 ), - True, - ), - ( - _mocked_device( - modules=[Module.Light], - alias="my_light", - features=[ - _mocked_feature("brightness", value=50), - _mocked_feature( - "color_temp", value=4000, minimum_value=4000, maximum_value=9000 - ), - ], - ), - False, - ), - ], -) -async def test_color_temp_light( - hass: HomeAssistant, device: MagicMock, is_color: bool -) -> None: - """Test a light.""" + ], + ) already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - # device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -358,29 +476,24 @@ async def test_color_temp_light( attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" - if is_color: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] - else: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] - assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 - assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -389,7 +502,7 @@ async def test_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) @@ -399,7 +512,7 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, blocking=True, ) @@ -409,7 +522,94 @@ async def test_color_temp_light( # Verify color temp is clamped to the valid range await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, + blocking=True, + ) + light.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + +async def test_color_temp_light_no_color(hass: HomeAssistant) -> None: + """Test a color temp light with no color.""" + device = _mocked_device( + modules=[Module.Light], + alias="my_light", + features=[ + _mocked_feature("brightness", value=50), + _mocked_feature( + "color_temp", value=4000, minimum_value=4000, maximum_value=9000 + ), + ], + ) + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + light = device.modules[Module.Light] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "color_temp" + + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] + assert attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 9000 + assert attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 4000 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + light.set_state.assert_called_once() + light.set_state.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, + blocking=True, + ) + light.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, + blocking=True, + ) + light.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + light.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, blocking=True, ) @@ -418,7 +618,7 @@ async def test_color_temp_light( async def test_brightness_only_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light brightness.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -430,7 +630,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -443,20 +643,20 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) @@ -465,7 +665,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: async def test_on_off_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light turns on and off.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -474,7 +674,7 @@ async def test_on_off_light(hass: HomeAssistant) -> None: light = device.modules[Module.Light] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -485,20 +685,20 @@ async def test_on_off_light(hass: HomeAssistant) -> None: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once() light.set_state.reset_mock() async def test_off_at_start_light(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a light off at startup.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -509,7 +709,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -521,7 +721,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: - """Test a light.""" + """Test a dimmer turns on without brightness being set.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -532,7 +732,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: light.state = LightState(light_on=False) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -541,7 +741,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: assert state.state == "off" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) light.set_state.assert_called_once_with( LightState( @@ -582,7 +782,7 @@ async def test_smart_strip_effects( _patch_single_discovery(device=device), _patch_connect(device=device), ): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -596,7 +796,7 @@ async def test_smart_strip_effects( # is in progress calls set_effect to clear the effect await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) @@ -607,7 +807,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, blocking=True, ) @@ -624,7 +824,7 @@ async def test_smart_strip_effects( # Test setting light effect off await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "off"}, blocking=True, ) @@ -639,7 +839,7 @@ async def test_smart_strip_effects( caplog.clear() await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect3"}, blocking=True, ) @@ -668,7 +868,7 @@ async def test_smart_strip_effects( await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -698,7 +898,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -708,7 +908,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -737,7 +937,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -787,7 +987,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -796,7 +996,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "random_effect", + SERVICE_RANDOM_EFFECT, { ATTR_ENTITY_ID: entity_id, "init_states": [340, 20, 50], @@ -839,6 +1039,84 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: light_effect.set_custom_effect.reset_mock() +@pytest.mark.parametrize( + ("service_name", "service_params", "expected_extra_params"), + [ + pytest.param( + SERVICE_SEQUENCE_EFFECT, + { + "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + }, + { + "type": "sequence", + "sequence": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + "repeat_times": 0, + "spread": 1, + "direction": 4, + }, + id="sequence", + ), + pytest.param( + SERVICE_RANDOM_EFFECT, + {"init_states": [340, 20, 50]}, + {"type": "random", "init_states": [[340, 20, 50]], "random_seed": 100}, + id="random", + ), + ], +) +async def test_smart_strip_effect_service_error( + hass: HomeAssistant, + service_name: str, + service_params: dict, + expected_extra_params: dict, +) -> None: + """Test smart strip effect service errors.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light_effect = device.modules[Module.LightEffect] + + with _patch_discovery(device=device), _patch_connect(device=device): + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.my_light" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + light_effect.set_custom_effect.side_effect = KasaException("failed") + + base = { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + } + expected_params = {**base, **expected_extra_params} + expected_msg = f"Error trying to set custom effect {expected_params}: failed" + + with pytest.raises(HomeAssistantError, match=re.escape(expected_msg)): + await hass.services.async_call( + DOMAIN, + service_name, + { + ATTR_ENTITY_ID: entity_id, + **service_params, + }, + blocking=True, + ) + + async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> None: """Test smart strip custom random effects at startup.""" already_migrated_config_entry = MockConfigEntry( @@ -852,7 +1130,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> light_effect = device.modules[Module.LightEffect] light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -862,7 +1140,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> # fallback to set HSV when custom effect is not known so it does turn back on await hass.services.async_call( LIGHT_DOMAIN, - "turn_on", + SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) @@ -882,7 +1160,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: light_effect = device.modules[Module.LightEffect] with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -892,7 +1170,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, - "sequence_effect", + SERVICE_SEQUENCE_EFFECT, { ATTR_ENTITY_ID: entity_id, "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], @@ -957,7 +1235,7 @@ async def test_light_errors_when_turned_on( light.set_state.side_effect = exception_type(msg) with _patch_discovery(device=device), _patch_connect(device=device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_light" @@ -968,7 +1246,7 @@ async def test_light_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert light.set_state.call_count == 1 @@ -1008,7 +1286,7 @@ async def test_light_child( ) with _patch_discovery(device=parent_device), _patch_connect(device=parent_device): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "light.my_device" @@ -1049,14 +1327,16 @@ async def test_scene_effect_light( light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF with _patch_discovery(device=device), _patch_connect(device=device): - assert await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) - assert await async_setup_component(hass, "scene", {}) + assert await hass.config_entries.async_setup( + already_migrated_config_entry.entry_id + ) + assert await async_setup_component(hass, SCENE_DOMAIN, {}) await hass.async_block_till_done() entity_id = "light.my_light" await hass.services.async_call( - LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1068,9 +1348,9 @@ async def test_scene_effect_light( assert state.attributes["effect"] is EFFECT_OFF await hass.services.async_call( - "scene", - "create", - {"scene_id": "effect_off_scene", "snapshot_entities": [entity_id]}, + SCENE_DOMAIN, + SERVICE_CREATE, + {CONF_SCENE_ID: "effect_off_scene", CONF_SNAPSHOT: [entity_id]}, blocking=True, ) await hass.async_block_till_done() @@ -1078,7 +1358,7 @@ async def test_scene_effect_light( assert scene_state.state is STATE_UNKNOWN await hass.services.async_call( - LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() freezer.tick(5) @@ -1089,10 +1369,10 @@ async def test_scene_effect_light( assert state.state is STATE_OFF await hass.services.async_call( - "scene", - "turn_on", + SCENE_DOMAIN, + SERVICE_TURN_ON, { - "entity_id": "scene.effect_off_scene", + ATTR_ENTITY_ID: "scene.effect_off_scene", }, blocking=True, ) diff --git a/tests/components/tplink/test_number.py b/tests/components/tplink/test_number.py index 865ce27ffc0..07d64178dfa 100644 --- a/tests/components/tplink/test_number.py +++ b/tests/components/tplink/test_number.py @@ -3,7 +3,6 @@ from kasa import Feature from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -15,11 +14,8 @@ from homeassistant.components.tplink.number import NUMBER_DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -28,6 +24,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -39,7 +36,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a number states.""" features = {description.key for description in NUMBER_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -54,7 +51,7 @@ async def test_states( async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test a sensor unique ids.""" + """Test number unique ids.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -70,7 +67,7 @@ async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) - ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -84,7 +81,7 @@ async def test_number_children( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: - """Test a sensor unique ids.""" + """Test number children.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -104,7 +101,7 @@ async def test_number_children( children=_mocked_strip_children(features=[new_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" @@ -142,7 +139,7 @@ async def test_number_set( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "number.my_plug_temperature_offset" diff --git a/tests/components/tplink/test_select.py b/tests/components/tplink/test_select.py index 6c49185d91c..3b99412740a 100644 --- a/tests/components/tplink/test_select.py +++ b/tests/components/tplink/test_select.py @@ -4,7 +4,6 @@ from kasa import Feature import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -16,11 +15,8 @@ from homeassistant.components.tplink.select import SELECT_DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_feature, _mocked_strip_children, @@ -29,13 +25,14 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @pytest.fixture def mocked_feature_select() -> Feature: - """Return mocked tplink binary sensor feature.""" + """Return mocked tplink select feature.""" return _mocked_feature( "light_preset", value="First choice", @@ -53,7 +50,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test select states.""" features = {description.key for description in SELECT_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +69,7 @@ async def test_select( entity_registry: er.EntityRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select unique ids.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -81,7 +78,7 @@ async def test_select( plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() # The entity_id is based on standard name from core. @@ -97,7 +94,7 @@ async def test_select_children( device_registry: dr.DeviceRegistry, mocked_feature_select: Feature, ) -> None: - """Test a sensor unique ids.""" + """Test select children.""" mocked_feature = mocked_feature_select already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -109,7 +106,7 @@ async def test_select_children( children=_mocked_strip_children(features=[mocked_feature]), ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" @@ -141,7 +138,7 @@ async def test_select_select( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=[mocked_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "select.my_plug_light_preset" diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index a53b59df0dc..857a2365527 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -4,18 +4,14 @@ from kasa import Device, Feature, Module import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.sensor import SENSOR_DESCRIPTIONS from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_energy_features, _mocked_feature, @@ -25,6 +21,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry @@ -36,7 +33,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a sensor states.""" features = {description.key for description in SENSOR_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -67,7 +64,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: alias="my_bulb", modules=[Module.Light], features=["state", *emeter_features] ) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -104,7 +101,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: ) plug = _mocked_device(alias="my_plug", features=["state", *emeter_features]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -131,7 +128,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: bulb = _mocked_device(alias="my_bulb", modules=[Module.Light]) with _patch_discovery(device=bulb), _patch_connect(device=bulb): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -167,7 +164,7 @@ async def test_sensor_unique_id( ) plug = _mocked_device(alias="my_plug", features=emeter_features) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() expected = { @@ -202,7 +199,7 @@ async def test_undefined_sensor( ) plug = _mocked_device(alias="my_plug", features=[new_feature]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() msg = ( @@ -240,7 +237,7 @@ async def test_sensor_children_on_parent( device_type=Device.Type.WallSwitch, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -288,7 +285,7 @@ async def test_sensor_children_on_child( device_type=Device.Type.Strip, ) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_this_month_s_consumption" @@ -308,19 +305,18 @@ async def test_sensor_children_on_child( assert child_device.via_device_id == device.id -@pytest.mark.skip -async def test_new_datetime_sensor( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_datetime_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test a sensor unique ids.""" - # Skipped temporarily while datetime handling on hold. + """Test a timestamp sensor.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["on_since"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "sensor.my_plug_on_since" diff --git a/tests/components/tplink/test_siren.py b/tests/components/tplink/test_siren.py index 8c3328558b0..1d820bca1d1 100644 --- a/tests/components/tplink/test_siren.py +++ b/tests/components/tplink/test_siren.py @@ -7,12 +7,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import _mocked_device, setup_platform_for_device, snapshot_platform @@ -74,3 +78,91 @@ async def test_turn_on_and_off( ) alarm_module.play.assert_called() + + +@pytest.mark.parametrize( + ("max_volume", "volume_level", "expected_volume"), + [ + pytest.param(3, 0.1, 1, id="smart-10%"), + pytest.param(3, 0.3, 1, id="smart-30%"), + pytest.param(3, 0.99, 3, id="smart-99%"), + pytest.param(3, 1, 3, id="smart-100%"), + pytest.param(10, 0.1, 1, id="smartcam-10%"), + pytest.param(10, 0.3, 3, id="smartcam-30%"), + pytest.param(10, 0.99, 10, id="smartcam-99%"), + pytest.param(10, 1, 10, id="smartcam-100%"), + ], +) +async def test_turn_on_with_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + max_volume: int, + volume_level: float, + expected_volume: int, +) -> None: + """Test that turn_on volume parameters work as expected.""" + + alarm_module = mocked_hub.modules[Module.Alarm] + alarm_volume_feat = alarm_module.get_feature("alarm_volume") + assert alarm_volume_feat + alarm_volume_feat.maximum_value = max_volume + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_VOLUME_LEVEL: volume_level}, + blocking=True, + ) + + alarm_module.play.assert_called_with( + volume=expected_volume, duration=None, sound=None + ) + + +async def test_turn_on_with_duration_and_sound( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, +) -> None: + """Test that turn_on tone and duration parameters work as expected.""" + + alarm_module = mocked_hub.modules[Module.Alarm] + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_DURATION: 5, ATTR_TONE: "Foo"}, + blocking=True, + ) + + alarm_module.play.assert_called_with(volume=None, duration=5, sound="Foo") + + +@pytest.mark.parametrize(("duration"), [0, 301]) +async def test_turn_on_with_invalid_duration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + duration: int, +) -> None: + """Test that turn_on with invalid_duration raises an error.""" + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + msg = f"Invalid duration {duration} available: 1-300s" + + with pytest.raises(ServiceValidationError, match=msg): + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: [ENTITY_ID], + ATTR_DURATION: duration, + }, + blocking=True, + ) diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index e9c8cc07b67..bdf54f10e8b 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -9,7 +9,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import tplink -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.components.tplink.entity import EXCLUDED_FEATURES from homeassistant.components.tplink.switch import SWITCH_DESCRIPTIONS @@ -25,12 +29,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from . import ( - DEVICE_ID, - MAC_ADDRESS, _mocked_device, _mocked_strip_children, _patch_connect, @@ -38,6 +39,7 @@ from . import ( setup_platform_for_device, snapshot_platform, ) +from .const import DEVICE_ID, MAC_ADDRESS from tests.common import MockConfigEntry, async_fire_time_changed @@ -49,7 +51,7 @@ async def test_states( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test a sensor unique ids.""" + """Test a switch states.""" features = {description.key for description in SWITCH_DESCRIPTIONS} features.update(EXCLUDED_FEATURES) device = _mocked_device(alias="my_device", features=features) @@ -72,7 +74,7 @@ async def test_plug(hass: HomeAssistant) -> None: plug = _mocked_device(alias="my_plug", features=["state"]) feat = plug.features["state"] with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -80,13 +82,13 @@ async def test_plug(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -120,7 +122,7 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None feat = dev.features["led"] already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(device=dev), _patch_connect(device=dev): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_name = slugify(dev.alias) @@ -131,13 +133,13 @@ async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None assert led_state.name == f"{dev.alias} LED" await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(False) feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) feat.set_value.assert_called_once_with(True) feat.set_value.reset_mock() @@ -153,7 +155,7 @@ async def test_plug_unique_id( already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -168,7 +170,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -197,7 +199,7 @@ async def test_strip(hass: HomeAssistant) -> None: strip.children[0].features["state"].value = True strip.children[1].features["state"].value = False with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_strip_plug0" @@ -205,14 +207,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_ON await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[0].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -222,14 +224,14 @@ async def test_strip(hass: HomeAssistant) -> None: assert state.state == STATE_OFF await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat = strip.children[1].features["state"] feat.set_value.assert_called_once() feat.set_value.reset_mock() await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) feat.set_value.assert_called_once() feat.set_value.reset_mock() @@ -249,7 +251,7 @@ async def test_strip_unique_ids( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() for plug_id in range(2): @@ -260,9 +262,11 @@ async def test_strip_unique_ids( async def test_strip_blank_alias( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test a strip unique id.""" + """Test a strip with blank parent alias.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) @@ -274,14 +278,30 @@ async def test_strip_blank_alias( features=["state", "led"], ) with _patch_discovery(device=strip), _patch_connect(device=strip): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() + strip_entity_id = "switch.unnamed_ks123" + state = hass.states.get(strip_entity_id) + assert state.name == "Unnamed KS123" + reg_ent = entity_registry.async_get(strip_entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + assert reg_dev.name == "Unnamed KS123" + for plug_id in range(2): entity_id = f"switch.unnamed_ks123_stripsocket_{plug_id + 1}" state = hass.states.get(entity_id) assert state.name == f"Unnamed KS123 Stripsocket {plug_id + 1}" + reg_ent = entity_registry.async_get(entity_id) + assert reg_ent + reg_dev = device_registry.async_get(reg_ent.device_id) + assert reg_dev + # Switch is a primary feature so entities go on the parent device. + assert reg_dev.name == "Unnamed KS123" + @pytest.mark.parametrize( ("exception_type", "msg", "reauth_expected"), @@ -320,7 +340,7 @@ async def test_plug_errors_when_turned_on( feat.set_value.side_effect = exception_type("test error") with _patch_discovery(device=plug), _patch_connect(device=plug): - await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.config_entries.async_setup(already_migrated_config_entry.entry_id) await hass.async_block_till_done() entity_id = "switch.my_plug" @@ -331,7 +351,7 @@ async def test_plug_errors_when_turned_on( with pytest.raises(HomeAssistantError, match=msg): await hass.services.async_call( - SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() assert feat.set_value.call_count == 1 diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py new file mode 100644 index 00000000000..55bb8c0b504 --- /dev/null +++ b/tests/components/tplink/test_vacuum.py @@ -0,0 +1,133 @@ +"""Tests for vacuum platform.""" + +from __future__ import annotations + +from kasa import Device, Module +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + translation, +) + +from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform + +from tests.common import MockConfigEntry + +ENTITY_ID = "vacuum.my_vacuum" + + +@pytest.fixture +async def mocked_vacuum(hass: HomeAssistant) -> Device: + """Return mocked tplink vacuum.""" + + return _mocked_device(modules=[Module.Clean, Module.Speaker], alias="my_vacuum") + + +async def test_vacuum( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_vacuum: Device, +) -> None: + """Test initialization.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries + + entity = entity_registry.async_get(ENTITY_ID) + assert entity + assert entity.unique_id == f"{DEVICE_ID}-vacuum" + + state = hass.states.get(ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + assert state.attributes[ATTR_FAN_SPEED] == "max" + assert state.attributes[ATTR_BATTERY_LEVEL] == 100 + result = translation.async_translate_state( + hass, "max", "vacuum", "tplink", "vacuum.state_attributes.fan_speed", None + ) + assert result == "Max" + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mocked_vacuum: Device, +) -> None: + """Test vacuum states.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service_call", "module_name", "method", "params"), + [ + (SERVICE_START, Module.Clean, "start", {}), + (SERVICE_PAUSE, Module.Clean, "pause", {}), + (SERVICE_RETURN_TO_BASE, Module.Clean, "return_home", {}), + ( + SERVICE_SET_FAN_SPEED, + Module.Clean, + "set_fan_speed_preset", + {ATTR_FAN_SPEED: "quiet"}, + ), + (SERVICE_LOCATE, Module.Speaker, "locate", {}), + ], +) +async def test_vacuum_module( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_vacuum: Device, + service_call: str, + module_name: str, + method: str, + params: dict, +) -> None: + """Test that all vacuum commands work correctly.""" + vacuum = mocked_vacuum + module = vacuum.modules[module_name] + + await setup_platform_for_device(hass, mock_config_entry, Platform.VACUUM, vacuum) + + mock_method = getattr(module, method) + + service_data = {ATTR_ENTITY_ID: ENTITY_ID} + service_data |= params + + await hass.services.async_call( + VACUUM_DOMAIN, service_call, service_data, blocking=True + ) + + # Is this required when using blocking=True? + await hass.async_block_till_done(wait_background_tasks=True) + + mock_method.assert_called() diff --git a/tests/components/tplink_omada/snapshots/test_sensor.ambr b/tests/components/tplink_omada/snapshots/test_sensor.ambr index 6c332eb9696..62167fc9d40 100644 --- a/tests/components/tplink_omada/snapshots/test_sensor.ambr +++ b/tests/components/tplink_omada/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -174,6 +177,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -232,6 +236,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +295,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index a13d386e721..dde196deaaf 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -71,6 +71,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -117,6 +118,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr index 4b610e927d5..761626347a7 100644 --- a/tests/components/tractive/snapshots/test_binary_sensor.ambr +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index 4e7c5bfe173..ef511299e68 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr index 11427a84801..3613f7e5997 100644 --- a/tests/components/tractive/snapshots/test_diagnostics.ambr +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr index f10cfb29226..4551492e36e 100644 --- a/tests/components/tractive/snapshots/test_sensor.ambr +++ b/tests/components/tractive/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +217,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -313,6 +319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -367,6 +374,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -419,6 +427,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,6 +484,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_switch.ambr b/tests/components/tractive/snapshots/test_switch.ambr index 08e0c984d0c..d443611ef92 100644 --- a/tests/components/tractive/snapshots/test_switch.ambr +++ b/tests/components/tractive/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index b6f38b1d83d..6d6215a21ab 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.tradfri import config_flow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import TRADFRI_PATH @@ -115,13 +118,13 @@ async def test_discovery_connection( flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -150,13 +153,13 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.124"), ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -174,13 +177,13 @@ async def test_duplicate_discovery( result = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -190,13 +193,13 @@ async def test_duplicate_discovery( result2 = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) @@ -215,13 +218,13 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: flow = await hass.config_entries.flow.async_init( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("123.123.123.123"), ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "homekit-id"}, + properties={ATTR_PROPERTIES_ID: "homekit-id"}, type="mock_type", ), ) diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 234269cc9f8..9d7a3873957 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from unittest.mock import patch import pytest -from pytrafikverket.models import TrainStopModel +from pytrafikverket import StationInfoModel, TrainStopModel from homeassistant.components.trafikverket_train.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -40,6 +40,9 @@ async def load_integration_from_entry( patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), ): await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -50,8 +53,8 @@ async def load_integration_from_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry.entry_id) @@ -61,8 +64,8 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG2, entry_id="2", - version=1, - minor_version=2, + version=2, + minor_version=1, ) config_entry2.add_to_hass(hass) await setup_config_entry_with_mocked_data(config_entry2.entry_id) @@ -171,3 +174,57 @@ def fixture_get_train_stop() -> TrainStopModel: modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), product_description=["Regionaltåg"], ) + + +@pytest.fixture(name="get_train_stations") +def fixture_get_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ) + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ) + ], + ] + + +@pytest.fixture(name="get_multiple_train_stations") +def fixture_get_multiple_train_station() -> list[list[StationInfoModel]]: + """Construct StationInfoModel Mock.""" + + return [ + [ + StationInfoModel( + signature="Cst", + station_name="Stockholm C", + advertised=True, + ), + StationInfoModel( + signature="Csu", + station_name="Stockholm City", + advertised=True, + ), + ], + [ + StationInfoModel( + signature="U", + station_name="Uppsala C", + advertised=True, + ), + StationInfoModel( + signature="Ups", + station_name="Uppsala City", + advertised=True, + ), + ], + ] diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index eac5e629bf0..241831b5553 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -5,14 +5,13 @@ from __future__ import annotations from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( +from pytrafikverket import ( InvalidAuthentication, - MultipleTrainStationsFound, - NoTrainAnnouncementFound, NoTrainStationFound, + StationInfoModel, + TrainStopModel, UnknownError, ) -from pytrafikverket.models import TrainStopModel from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -26,24 +25,27 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import ENTRY_CONFIG, OPTIONS_CONFIG + from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" assert result["errors"] == {} with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -67,8 +69,8 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == { "api_key": "1234567890", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "fri"], } @@ -76,7 +78,70 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_entry_already_exist(hass: HomeAssistant) -> None: +async def test_form_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stockholm C to Uppsala C at 10:00" + assert result["data"] == { + "api_key": "1234567890", + "name": "Stockholm C to Uppsala C at 10:00", + "from": "Csu", + "to": "Ups", + "time": "10:00", + "weekday": ["mon", "fri"], + } + assert result["options"] == {"filter_product": None} + + +async def test_form_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test flow aborts when entry already exist.""" entry = MockConfigEntry( @@ -84,14 +149,14 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, CONF_FILTER_PRODUCT: None, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -103,10 +168,11 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -130,28 +196,24 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", - ), - ( - MultipleTrainStationsFound, - "more_stations", + {"from": "invalid_station", "to": "invalid_station"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -159,16 +221,13 @@ async def test_flow_fails( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -179,24 +238,24 @@ async def test_flow_fails( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error @pytest.mark.parametrize( - ("side_effect", "base_error"), + ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_flow_fails_departures( - hass: HomeAssistant, side_effect: Exception, base_error: str + hass: HomeAssistant, side_effect: Exception, p_error: dict[str, str] ) -> None: """Test config flow errors.""" result = await hass.config_entries.flow.async_init( @@ -204,19 +263,13 @@ async def test_flow_fails_departures( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "initial" with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -227,23 +280,25 @@ async def test_flow_fails_departures( }, ) - assert result["errors"] == {"base": base_error} + assert result["errors"] == p_error -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -254,10 +309,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -275,8 +328,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -287,24 +340,27 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: [ ( InvalidAuthentication, - "invalid_auth", + {"base": "invalid_auth"}, ), ( NoTrainStationFound, - "invalid_station", + {"from": "invalid_station"}, ), ( - MultipleTrainStationsFound, - "more_stations", + UnknownError, + {"base": "cannot_connect"}, ), ( Exception, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -312,13 +368,13 @@ async def test_reauth_flow_error( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -326,12 +382,9 @@ async def test_reauth_flow_error( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -341,14 +394,12 @@ async def test_reauth_flow_error( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", @@ -366,8 +417,8 @@ async def test_reauth_flow_error( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -377,17 +428,20 @@ async def test_reauth_flow_error( ("side_effect", "p_error"), [ ( - NoTrainAnnouncementFound, - "no_trains", + NoTrainStationFound, + {"from": "invalid_station"}, ), ( UnknownError, - "cannot_connect", + {"base": "cannot_connect"}, ), ], ) async def test_reauth_flow_error_departures( - hass: HomeAssistant, side_effect: Exception, p_error: str + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -395,13 +449,13 @@ async def test_reauth_flow_error_departures( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -409,10 +463,7 @@ async def test_reauth_flow_error_departures( with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", side_effect=side_effect(), ), ): @@ -424,11 +475,12 @@ async def test_reauth_flow_error_departures( assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": p_error} + assert result["errors"] == p_error with ( patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -449,8 +501,8 @@ async def test_reauth_flow_error_departures( assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", - "from": "Stockholm C", - "to": "Uppsala C", + "from": "Cst", + "to": "U", "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } @@ -460,6 +512,7 @@ async def test_options_flow( hass: HomeAssistant, get_trains: list[TrainStopModel], get_train_stop: TrainStopModel, + get_train_stations: list[StationInfoModel], ) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( @@ -467,24 +520,28 @@ async def test_options_flow( data={ CONF_API_KEY: "1234567890", CONF_NAME: "Stockholm C to Uppsala C at 10:00", - CONF_FROM: "Stockholm C", - CONF_TO: "Uppsala C", + CONF_FROM: "Cst", + CONF_TO: "U", CONF_TIME: "10:00", CONF_WEEKDAY: WEEKDAYS, }, - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", return_value=get_trains, ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", return_value=get_train_stop, @@ -520,3 +577,328 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {"filter_product": None} + + +async def test_reconfigure_flow( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_multiple_stations( + hass: HomeAssistant, get_multiple_train_stations: list[StationInfoModel] +) -> None: + """Test we can reconfigure with multiple stations.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: ["mon", "fri"], + }, + ) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FROM: "Csu", + CONF_TO: "Ups", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_entry_already_exist( + hass: HomeAssistant, get_train_stations: list[StationInfoModel] +) -> None: + """Test flow aborts when entry already exist in a reconfigure flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Cst", + CONF_TO: "U", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_FILTER_PRODUCT: None, + }, + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + InvalidAuthentication, + {"base": "invalid_auth"}, + ), + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + Exception, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + NoTrainStationFound, + {"from": "invalid_station", "to": "invalid_station"}, + ), + ( + UnknownError, + {"base": "cannot_connect"}, + ), + ], +) +async def test_reconfigure_flow_fails_departures( + hass: HomeAssistant, + side_effect: Exception, + p_error: dict[str, str], + get_train_stations: list[StationInfoModel], +) -> None: + """Test config flow errors.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + version=2, + minor_version=1, + ) + config_entry2.add_to_hass(hass) + result = await config_entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "initial" + + with ( + patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + + assert result["errors"] == p_error + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, + ), + patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py index 41c8e2432ef..cb048365700 100644 --- a/tests/components/trafikverket_train/test_init.py +++ b/tests/components/trafikverket_train/test_init.py @@ -4,8 +4,14 @@ from __future__ import annotations from unittest.mock import patch -from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound -from pytrafikverket.models import TrainStopModel +import pytest +from pytrafikverket import ( + InvalidAuthentication, + NoTrainStationFound, + StationInfoModel, + TrainStopModel, + UnknownError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components.trafikverket_train.const import DOMAIN @@ -28,14 +34,14 @@ async def test_unload_entry( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -65,13 +71,13 @@ async def test_auth_failed( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=InvalidAuthentication, ): await hass.config_entries.async_setup(entry.entry_id) @@ -96,13 +102,13 @@ async def test_no_stations( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", side_effect=NoTrainStationFound, ): await hass.config_entries.async_setup(entry.entry_id) @@ -124,8 +130,8 @@ async def test_migrate_entity_unique_id( data=ENTRY_CONFIG, options=OPTIONS_CONFIG, entry_id="1", - version=1, - minor_version=2, + version=2, + minor_version=1, ) entry.add_to_hass(hass) @@ -139,7 +145,7 @@ async def test_migrate_entity_unique_id( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -158,8 +164,9 @@ async def test_migrate_entity_unique_id( async def test_migrate_entry( hass: HomeAssistant, get_trains: list[TrainStopModel], + get_train_stations: list[StationInfoModel], ) -> None: - """Test migrate entry unique id.""" + """Test migrate entry.""" entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -174,7 +181,11 @@ async def test_migrate_entry( with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_station_from_signature", + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_train_stations, ), patch( "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", @@ -186,8 +197,18 @@ async def test_migrate_entry( assert entry.state is ConfigEntryState.LOADED - assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.version == 2 + assert entry.minor_version == 1 + # Migration to version 2.1 changed from/to to use station signatures + assert entry.data == { + "api_key": "1234567890", + "from": "Cst", + "to": "U", + "time": None, + "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "name": "Stockholm C to Uppsala C", + } + # Migration to version 1.2 removed unique_id assert entry.unique_id is None @@ -201,18 +222,73 @@ async def test_migrate_entry_from_future_version_fails( source=SOURCE_USER, data=ENTRY_CONFIG, options=OPTIONS_CONFIG, - version=2, + version=3, + minor_version=1, + entry_id="1", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ("side_effect"), + [ + (InvalidAuthentication), + (NoTrainStationFound), + (UnknownError), + (Exception), + ], +) +async def test_migrate_entry_fails(hass: HomeAssistant, side_effect: Exception) -> None: + """Test migrate entry fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, entry_id="1", ) entry.add_to_hass(hass) with ( patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_station", - ), - patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", - return_value=get_trains, + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_search_train_stations", + side_effect=side_effect(), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_entry_fails_multiple_stations( + hass: HomeAssistant, + get_multiple_train_stations: list[StationInfoModel], +) -> None: + """Test migrate entry fails on multiple stations found.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + version=1, + minor_version=1, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_search_train_stations", + side_effect=get_multiple_train_stations, ), ): await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index b1eae12d694..921cab4cba2 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -24,7 +24,7 @@ from homeassistant.components.tts import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component @@ -249,7 +249,7 @@ async def mock_config_entry_setup( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test tts platform via config entry.""" async_add_entities([tts_entity]) diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py new file mode 100644 index 00000000000..d82ec6a5d2b --- /dev/null +++ b/tests/components/tts/test_entity.py @@ -0,0 +1,144 @@ +"""Tests for the TTS entity.""" + +import pytest + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, State + +from .common import ( + DEFAULT_LANG, + SUPPORT_LANGUAGES, + TEST_DOMAIN, + MockTTSEntity, + mock_config_entry_setup, +) + +from tests.common import mock_restore_cache + + +class DefaultEntity(tts.TextToSpeechEntity): + """Test entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_default_language = DEFAULT_LANG + + +async def test_default_entity_attributes() -> None: + """Test default entity attributes.""" + entity = DefaultEntity() + + assert entity.hass is None + assert entity.default_language == DEFAULT_LANG + assert entity.supported_languages == SUPPORT_LANGUAGES + assert entity.supported_options is None + assert entity.default_options is None + assert entity.async_get_supported_voices("test") is None + + +async def test_restore_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +async def test_tts_entity_subclass_properties( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" + + class TestClass1(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass1()) + + class TestClass2(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass2()) + + assert all(record.exc_info is None for record in caplog.records) + + caplog.clear() + + class TestClass3(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass3()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass4(tts.TextToSpeechEntity): + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass4()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass5(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass5()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass6(tts.TextToSpeechEntity): + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass6()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 0b01a24720d..1b9692cc70c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -20,15 +20,13 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, - SUPPORT_LANGUAGES, TEST_DOMAIN, MockTTS, MockTTSEntity, @@ -39,39 +37,12 @@ from .common import ( retrieve_media, ) -from tests.common import ( - MockModule, - async_mock_service, - mock_integration, - mock_platform, - mock_restore_cache, - reset_translation_cache, -) +from tests.common import MockModule, async_mock_service, mock_integration, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags -class DefaultEntity(tts.TextToSpeechEntity): - """Test entity.""" - - _attr_supported_languages = SUPPORT_LANGUAGES - _attr_default_language = DEFAULT_LANG - - -async def test_default_entity_attributes() -> None: - """Test default entity attributes.""" - entity = DefaultEntity() - - assert entity.hass is None - assert entity.name is UNDEFINED - assert entity.default_language == DEFAULT_LANG - assert entity.supported_languages == SUPPORT_LANGUAGES - assert entity.supported_options is None - assert entity.default_options is None - assert entity.async_get_supported_voices("test") is None - - async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -123,24 +94,6 @@ async def test_config_entry_unload( assert state is None -async def test_restore_state( - hass: HomeAssistant, - mock_tts_entity: MockTTSEntity, -) -> None: - """Test we restore state in the integration.""" - entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" - timestamp = "2023-01-01T23:59:59+00:00" - mock_restore_cache(hass, (State(entity_id, timestamp),)) - - config_entry = await mock_config_entry_setup(hass, mock_tts_entity) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - state = hass.states.get(entity_id) - assert state - assert state.state == timestamp - - @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) @@ -1200,7 +1153,7 @@ async def test_service_get_tts_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -1423,29 +1376,6 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None assert tts.async_resolve_engine(hass, None) is None -@pytest.mark.parametrize( - ("setup", "engine_id"), - [ - ("mock_setup", "test"), - ("mock_config_entry_setup", "tts.test"), - ], - indirect=["setup"], -) -async def test_support_options(hass: HomeAssistant, setup: str, engine_id: str) -> None: - """Test supporting options.""" - assert await tts.async_support_options(hass, engine_id, "en_US") is True - assert await tts.async_support_options(hass, engine_id, "nl") is False - assert ( - await tts.async_support_options( - hass, engine_id, "en_US", {"invalid_option": "yo"} - ) - is False - ) - - with pytest.raises(HomeAssistantError): - await tts.async_support_options(hass, "non-existing") - - async def test_legacy_fetching_in_async( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: @@ -1843,96 +1773,6 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") -async def test_ttsentity_subclass_properties( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" - - class TestClass1(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass1()) - - class TestClass2(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass2()) - - assert all(record.exc_info is None for record in caplog.records) - - caplog.clear() - - class TestClass3(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass3()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass4(tts.TextToSpeechEntity): - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass4()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass5(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass5()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass6(tts.TextToSpeechEntity): - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass6()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - - async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, @@ -1989,6 +1829,3 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" - - # Reset the `cloud` translations cache to avoid flaky translation checks - reset_translation_cache(hass, ["cloud"]) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index d90923b02ab..9e50cc6b512 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -268,7 +268,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": 5}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -284,7 +284,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": [5, 6]}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -300,5 +300,5 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": {"k1": [5, 6], "k2": "v2"}}, - "cache": True, + "use_file_cache": True, } diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index a5a68a12a22..90d83d69814 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '12345', 'unique_id': '12345', 'version': 1, @@ -54,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Old Tuya configuration entry', 'unique_id': '12345', 'version': 1, @@ -107,10 +111,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'mocked_username', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'mocked_username', 'type': , 'version': 1, diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 1df4beb4232..0576fcd6a70 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -51,6 +51,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 86ffc171082..b40ac0ba9e6 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -129,6 +132,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -178,6 +182,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +213,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -257,6 +263,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +294,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -336,6 +344,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -366,6 +375,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 814dc7dfc1f..511bf9addd3 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -69,6 +69,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Twinkly', 'unique_id': '00:2d:13:3b:aa:bb', 'version': 1, diff --git a/tests/components/twinkly/snapshots/test_light.ambr b/tests/components/twinkly/snapshots/test_light.ambr index a97c3f941ff..77a97a0cdd9 100644 --- a/tests/components/twinkly/snapshots/test_light.ambr +++ b/tests/components/twinkly/snapshots/test_light.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr index 21e09d6b022..6700aecd1f2 100644 --- a/tests/components/twinkly/snapshots/test_select.ambr +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -16,6 +16,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -37,7 +38,7 @@ 'platform': 'twinkly', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'mode', 'unique_id': '00:2d:13:3b:aa:bb_mode', 'unit_of_measurement': None, }) diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 2b61b26fe0c..352c5249b0b 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock import pytest -from homeassistant.components import dhcp from homeassistant.components.twinkly.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import TEST_MAC, TEST_MODEL, TEST_NAME @@ -95,7 +95,7 @@ async def test_dhcp_full_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", @@ -127,7 +127,7 @@ async def test_dhcp_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", @@ -146,7 +146,7 @@ async def test_user_flow_works_discovery(hass: HomeAssistant) -> None: await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="Twinkly_XYZ", ip="1.2.3.4", macaddress="002d133baabb", diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index acf30764bab..f8289cb95e3 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -140,7 +140,7 @@ async def test_turn_on_with_color_rgbw( ) mock_twinkly_client.interview.assert_called_once_with() - mock_twinkly_client.set_static_colour.assert_called_once_with((128, 64, 32)) + mock_twinkly_client.set_static_colour.assert_called_once_with((0, 128, 64, 32)) mock_twinkly_client.set_mode.assert_called_once_with("color") assert mock_twinkly_client.default_mode == "color" diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py index a4a9aea18c8..ba547c5eecc 100644 --- a/tests/components/uk_transport/test_sensor.py +++ b/tests/components/uk_transport/test_sensor.py @@ -8,6 +8,7 @@ import requests_mock from homeassistant.components.uk_transport.sensor import ( ATTR_ATCOCODE, ATTR_CALLING_AT, + ATTR_LAST_UPDATED, ATTR_LOCALITY, ATTR_NEXT_BUSES, ATTR_NEXT_TRAINS, @@ -90,3 +91,4 @@ async def test_train(hass: HomeAssistant) -> None: == "London Waterloo" ) assert train_state.attributes[ATTR_NEXT_TRAINS][0]["estimated"] == "06:13" + assert train_state.attributes[ATTR_LAST_UPDATED] == "2017-07-10T06:10:05+01:00" diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 798b613b18d..4075aa0ad59 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -172,8 +172,10 @@ def fixture_request( device_payload: list[dict[str, Any]], dpi_app_payload: list[dict[str, Any]], dpi_group_payload: list[dict[str, Any]], + firewall_policy_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], traffic_rule_payload: list[dict[str, Any]], + traffic_route_payload: list[dict[str, Any]], site_payload: list[dict[str, Any]], system_information_payload: list[dict[str, Any]], wlan_payload: list[dict[str, Any]], @@ -210,10 +212,14 @@ def fixture_request( mock_get_request(f"/api/s/{site_id}/stat/device", device_payload) mock_get_request(f"/api/s/{site_id}/rest/dpiapp", dpi_app_payload) mock_get_request(f"/api/s/{site_id}/rest/dpigroup", dpi_group_payload) + mock_get_request( + f"/v2/api/site/{site_id}/firewall-policies", firewall_policy_payload + ) mock_get_request(f"/api/s/{site_id}/rest/portforward", port_forward_payload) mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) mock_get_request(f"/v2/api/site/{site_id}/trafficrules", traffic_rule_payload) + mock_get_request(f"/v2/api/site/{site_id}/trafficroutes", traffic_route_payload) return __mock_requests @@ -251,6 +257,12 @@ def fixture_dpi_group_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="firewall_policy_payload") +def firewall_policy_payload_data() -> list[dict[str, Any]]: + """Firewall policy data.""" + return [] + + @pytest.fixture(name="port_forward_payload") def fixture_port_forward_data() -> list[dict[str, Any]]: """Port forward data.""" @@ -291,6 +303,12 @@ def traffic_rule_payload_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="traffic_route_payload") +def traffic_route_payload_data() -> list[dict[str, Any]]: + """Traffic route data.""" + return [] + + @pytest.fixture(name="wlan_payload") def fixture_wlan_data() -> list[dict[str, Any]]: """WLAN data.""" diff --git a/tests/components/unifi/snapshots/test_button.ambr b/tests/components/unifi/snapshots/test_button.ambr index 3729bd31cf0..369b0823063 100644 --- a/tests/components/unifi/snapshots/test_button.ambr +++ b/tests/components/unifi/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_device_tracker.ambr b/tests/components/unifi/snapshots/test_device_tracker.ambr index 3debd512050..5d3407e4e8e 100644 --- a/tests/components/unifi/snapshots/test_device_tracker.ambr +++ b/tests/components/unifi/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index 4ba90a00113..aa7337be0ba 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -42,6 +42,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '1', 'version': 1, diff --git a/tests/components/unifi/snapshots/test_image.ambr b/tests/components/unifi/snapshots/test_image.ambr index 32e1a5ff622..05cca2c305b 100644 --- a/tests/components/unifi/snapshots/test_image.ambr +++ b/tests/components/unifi/snapshots/test_image.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index e14658b2b96..4d109f630c5 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -70,6 +71,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +133,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -228,6 +232,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +287,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -336,6 +342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -385,6 +392,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -435,6 +443,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -485,6 +494,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -549,6 +559,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -610,6 +621,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -659,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +721,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -759,6 +773,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -810,6 +825,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -861,6 +877,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -912,6 +929,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -963,6 +981,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1014,6 +1033,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1065,6 +1085,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1119,6 +1140,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1173,6 +1195,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1224,6 +1247,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1278,6 +1302,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1332,6 +1357,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1386,6 +1412,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1440,6 +1467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1491,6 +1519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1545,6 +1574,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1612,6 +1642,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1673,6 +1704,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1722,6 +1754,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1771,6 +1804,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1822,6 +1856,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1871,6 +1906,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1920,6 +1956,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1971,6 +2008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2020,6 +2058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_switch.ambr b/tests/components/unifi/snapshots/test_switch.ambr index 45e6188a3f4..c07a4799b5a 100644 --- a/tests/components/unifi/snapshots/test_switch.ambr +++ b/tests/components/unifi/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -381,6 +389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,6 +485,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_update.ambr b/tests/components/unifi/snapshots/test_update.ambr index 405cb9d52a6..ef3803ac53d 100644 --- a/tests/components/unifi/snapshots/test_update.ambr +++ b/tests/components/unifi/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +186,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index fc3aeccea9f..94343d12ba2 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, @@ -262,7 +262,7 @@ async def test_device_button_entities( WLAN_REGENERATE_PASSWORD, "button.ssid_1_regenerate_password", "put", - f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]["_id"]}", + f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]['_id']}", { "json": {"data": "password changed successfully", "meta": {"rc": "ok"}}, "headers": {"content-type": CONTENT_TYPE_JSON}, diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 71b196550da..9d85dedbc9a 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -7,7 +7,6 @@ import aiounifi import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.unifi.config_flow import _async_discover_unifi from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -33,6 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .conftest import ConfigEntryFactoryType @@ -482,7 +482,7 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://192.168.208.1:41417/rootDesc.xml", @@ -522,7 +522,7 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -544,7 +544,7 @@ async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", @@ -570,7 +570,7 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:1234/rootDesc.xml", diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index c653370656d..39b70344db7 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.components.unifi.const import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, @@ -589,14 +589,14 @@ async def test_restoring_client( entity_registry.async_get_or_create( # Make sure unique ID converts to site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'{clients_all_payload[0]["mac"]}-site_id', + f"{clients_all_payload[0]['mac']}-site_id", suggested_object_id=clients_all_payload[0]["hostname"], config_entry=config_entry, ) entity_registry.async_get_or_create( # Unique ID already follow format site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'site_id-{client_payload[0]["mac"]}', + f"site_id-{client_payload[0]['mac']}", suggested_object_id=client_payload[0]["hostname"], config_entry=config_entry, ) diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index af134c7449b..8b129d3d648 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ConfigEntryFactoryType, WebsocketStateManager @@ -76,7 +76,7 @@ async def test_reset_fails( return_value=False, ): assert not await hass.config_entries.async_unload(config_entry_setup.entry_id) - assert config_entry_setup.state is ConfigEntryState.LOADED + assert config_entry_setup.state is ConfigEntryState.FAILED_UNLOAD @pytest.mark.usefixtures("mock_device_registry") diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 5e47d263079..ee8b102edaa 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ef93afa7e3e..c8ee786895c 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -809,6 +809,63 @@ TRAFFIC_RULE = { "target_devices": [{"client_mac": CLIENT_1["mac"], "type": "CLIENT"}], } +TRAFFIC_ROUTE = { + "_id": "676f8dbb8f1d54503bba19ab", + "description": "Test traffic route", + "domains": [{"domain": "youtube.com", "port_ranges": [], "ports": []}], + "enabled": True, + "ip_addresses": [], + "ip_ranges": [], + "kill_switch_enabled": True, + "matching_target": "DOMAIN", + "network_id": "676f8d288f1d54503bba1987", + "next_hop": "", + "regions": [], + "target_devices": [ + {"network_id": "6060b00f45de3905133cea14", "type": "NETWORK"}, + {"network_id": "6060ae6045de3905133cea0a", "type": "NETWORK"}, + ], +} + +FIREWALL_POLICY = { + "_id": "678ceb9fe3849d293243405c", + "action": "ALLOW", + "connection_state_type": "ALL", + "connection_states": [], + "create_allow_respond": True, + "description": "", + "destination": { + "match_opposite_ports": False, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "678ccc26e3849d2932432e26", + }, + "enabled": True, + "icmp_typename": "ANY", + "icmp_v6_typename": "ANY", + "index": 10000, + "ip_version": "BOTH", + "logging": False, + "match_ip_sec": False, + "match_opposite_protocol": False, + "name": "Allow internal to IoT", + "predefined": False, + "protocol": "all", + "schedule": { + "mode": "EVERY_DAY", + "repeat_on_days": [], + "time_all_day": False, + "time_range_end": "12:00", + "time_range_start": "09:00", + }, + "source": { + "match_opposite_ports": False, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "678c63bc2d97692f08adcdfa", + }, +} + @pytest.mark.parametrize( "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] @@ -1154,6 +1211,116 @@ async def test_traffic_rules( assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call +@pytest.mark.parametrize(("traffic_route_payload"), [([TRAFFIC_ROUTE])]) +async def test_traffic_routes( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + traffic_route_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi traffic routes.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + assert hass.states.get("switch.unifi_network_test_traffic_route").state == STATE_ON + + traffic_route = deepcopy(traffic_route_payload[0]) + + # Disable traffic route + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/trafficroutes/{traffic_route['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + # Updating the value for traffic routes will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(traffic_route) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable traffic route + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_test_traffic_route"}, + blocking=True, + ) + + expected_enable_call = deepcopy(traffic_route) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + +@pytest.mark.parametrize(("firewall_policy_payload"), [([FIREWALL_POLICY])]) +async def test_firewall_policies( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + firewall_policy_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi firewall policies.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + assert ( + hass.states.get("switch.unifi_network_allow_internal_to_iot").state == STATE_ON + ) + + firewall_policy = deepcopy(firewall_policy_payload[0]) + + # Disable firewall policy + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/firewall-policies/{firewall_policy['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_allow_internal_to_iot"}, + blocking=True, + ) + # Updating the value for firewall policies will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(firewall_policy) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable firewall policy + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_allow_internal_to_iot"}, + blocking=True, + ) + + expected_enable_call = deepcopy(firewall_policy) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ @@ -1577,14 +1744,14 @@ async def test_updating_unique_id( entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[0]["mac"]}-outlet-1', + f"{device_payload[0]['mac']}-outlet-1", suggested_object_id="plug_outlet_1", config_entry=config_entry, ) entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{device_payload[1]["mac"]}-poe-1', + f"{device_payload[1]['mac']}-poe-1", suggested_object_id="switch_port_1_poe", config_entry=config_entry, ) @@ -1605,6 +1772,7 @@ async def test_updating_unique_id( @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) @pytest.mark.parametrize("port_forward_payload", [[PORT_FORWARD_PLEX]]) @pytest.mark.parametrize(("traffic_rule_payload"), [([TRAFFIC_RULE])]) +@pytest.mark.parametrize("firewall_policy_payload", [[FIREWALL_POLICY]]) @pytest.mark.parametrize("wlan_payload", [[WLAN]]) @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -1619,6 +1787,7 @@ async def test_hub_state_change( "switch.block_media_streaming", "switch.unifi_network_plex", "switch.unifi_network_test_traffic_rule", + "switch.unifi_network_allow_internal_to_iot", "switch.ssid_1", ) for entity_id in entity_ids: diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 352c33297ba..c49ade514bc 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -33,7 +33,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _patch_discovery from .utils import MockUFPFixture diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 12b92beedd0..975e93edf09 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -174,7 +174,7 @@ def validate_common_camera_state( entity_id: str, features: int = CameraEntityFeature.STREAM, ): - """Validate state that is common to all camera entity, regradless of type.""" + """Validate state that is common to all camera entity, regardless of type.""" entity_state = hass.states.get(entity_id) assert entity_state assert entity_state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 8bfdc004092..0eae2a48fea 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -11,7 +11,6 @@ from uiprotect import NotAuthorized, NvrError, ProtectApiClient from uiprotect.data import NVR, Bootstrap, CloudAccount from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp from homeassistant.components.unifiprotect.const import ( CONF_ALL_UPDATES, CONF_DISABLE_RTSP, @@ -23,6 +22,8 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( DEVICE_HOSTNAME, @@ -37,13 +38,13 @@ from .conftest import MAC_ADDR from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname=DEVICE_HOSTNAME, ip=DEVICE_IP_ADDRESS, macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), ) SSDP_DISCOVERY = ( - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml", @@ -338,7 +339,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - ], ) async def test_discovered_by_ssdp_or_dhcp( - hass: HomeAssistant, source: str, data: dhcp.DhcpServiceInfo | ssdp.SsdpServiceInfo + hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo ) -> None: """Test we handoff to unifi-discovery when discovered via ssdp or dhcp.""" diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 5a1ffa8258e..7dd0362f17c 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5ebfd2c13ad..351e11db512 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaClass, MediaPlayerEntityFeature, ) -import homeassistant.components.universal.media_player as universal +from homeassistant.components.universal import media_player as universal from homeassistant.const import ( SERVICE_RELOAD, STATE_OFF, @@ -27,7 +27,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import async_mock_service, get_fixture_path +from tests.common import MockEntityPlatform, async_mock_service, get_fixture_path CONFIG_CHILDREN_ONLY = { "name": "test", @@ -74,6 +74,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): self._shuffle = False self._sound_mode = None self._repeat = None + self.platform = MockEntityPlatform(hass) self.service_calls = { "turn_on": async_mock_service( @@ -361,26 +362,10 @@ async def test_config_bad_key(hass: HomeAssistant) -> None: async def test_platform_setup(hass: HomeAssistant) -> None: """Test platform setup.""" config = {"name": "test", "platform": "universal"} - bad_config = {"platform": "universal"} - entities = [] - - def add_entities(new_entities): - """Add devices to list.""" - entities.extend(new_entities) - - setup_ok = True - try: - await universal.async_setup_platform( - hass, validate_config(bad_config), add_entities - ) - except MultipleInvalid: - setup_ok = False - assert not setup_ok - assert len(entities) == 0 - - await universal.async_setup_platform(hass, validate_config(config), add_entities) - assert len(entities) == 1 - assert entities[0].name == "test" + assert await async_setup_component(hass, "media_player", {"media_player": config}) + await hass.async_block_till_done() + assert hass.states.async_all() != [] + assert hass.states.get("media_player.test") is not None async def test_master_state(hass: HomeAssistant) -> None: @@ -461,11 +446,10 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: async def test_name(hass: HomeAssistant) -> None: """Test name property.""" - config = validate_config(CONFIG_CHILDREN_ONLY) - - ump = universal.UniversalMediaPlayer(hass, config) - - assert config["name"] == ump.name + assert await async_setup_component( + hass, "media_player", {"media_player": CONFIG_CHILDREN_ONLY} + ) + assert hass.states.get("media_player.test") is not None async def test_polling(hass: HomeAssistant) -> None: diff --git a/tests/components/upb/test_config_flow.py b/tests/components/upb/test_config_flow.py index 59a4e97d22b..3909c7e5dc4 100644 --- a/tests/components/upb/test_config_flow.py +++ b/tests/components/upb/test_config_flow.py @@ -13,15 +13,20 @@ from homeassistant.data_entry_flow import FlowResultType def mocked_upb(sync_complete=True, config_ok=True): """Mock UPB lib.""" - def _upb_lib_connect(callback): + def _add_handler(_, callback): callback() + def _dummy_add_handler(_, _callback): + pass + upb_mock = AsyncMock() type(upb_mock).network_id = PropertyMock(return_value="42") type(upb_mock).config_ok = PropertyMock(return_value=config_ok) type(upb_mock).disconnect = MagicMock() - if sync_complete: - upb_mock.async_connect.side_effect = _upb_lib_connect + type(upb_mock).add_handler = MagicMock() + upb_mock.add_handler.side_effect = ( + _add_handler if sync_complete else _dummy_add_handler + ) return patch( "homeassistant.components.upb.config_flow.upb_lib.UpbPim", return_value=upb_mock ) diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 202b3d32509..55138430ca0 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockUpdateEntity diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index d4916de8039..f3eb3f9344c 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -43,7 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component @@ -857,7 +857,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test update platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 5576128eae5..300d925b82b 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -25,6 +25,15 @@ from homeassistant.components.upnp.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -36,7 +45,7 @@ TEST_LOCATION6 = "http://[fe80::1%2]/desc.xml" TEST_HOST = urlparse(TEST_LOCATION).hostname TEST_FRIENDLY_NAME = "mock-name" TEST_MAC_ADDRESS = "00:11:22:33:44:55" -TEST_DISCOVERY = ssdp.SsdpServiceInfo( +TEST_DISCOVERY = SsdpServiceInfo( ssdp_st=TEST_ST, ssdp_udn=TEST_UDN, ssdp_usn=TEST_USN, @@ -45,12 +54,12 @@ TEST_DISCOVERY = ssdp.SsdpServiceInfo( "_udn": TEST_UDN, "location": TEST_LOCATION, "usn": TEST_USN, - ssdp.ATTR_UPNP_DEVICE_TYPE: TEST_ST, - ssdp.ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, - ssdp.ATTR_UPNP_MANUFACTURER: "mock-manufacturer", - ssdp.ATTR_UPNP_MODEL_NAME: "mock-model-name", - ssdp.ATTR_UPNP_SERIAL: "mock-serial", - ssdp.ATTR_UPNP_UDN: TEST_UDN, + ATTR_UPNP_DEVICE_TYPE: TEST_ST, + ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_UPNP_MANUFACTURER: "mock-manufacturer", + ATTR_UPNP_MODEL_NAME: "mock-model-name", + ATTR_UPNP_SERIAL: "mock-serial", + ATTR_UPNP_UDN: TEST_UDN, }, ssdp_headers={ "_host": TEST_HOST, @@ -75,13 +84,13 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: """Mock async_upnp_client device.""" mock_upnp_device = create_autospec(UpnpDevice, instance=True) mock_upnp_device.device_url = TEST_DISCOVERY.ssdp_location - mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_SERIAL] + mock_upnp_device.serial_number = TEST_DISCOVERY.upnp[ATTR_UPNP_SERIAL] mock_igd_device = create_autospec(IgdDevice) mock_igd_device.device_type = TEST_DISCOVERY.ssdp_st - mock_igd_device.name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] - mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MANUFACTURER] - mock_igd_device.model_name = TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + mock_igd_device.name = TEST_DISCOVERY.upnp[ATTR_UPNP_FRIENDLY_NAME] + mock_igd_device.manufacturer = TEST_DISCOVERY.upnp[ATTR_UPNP_MANUFACTURER] + mock_igd_device.model_name = TEST_DISCOVERY.upnp[ATTR_UPNP_MODEL_NAME] mock_igd_device.udn = TEST_DISCOVERY.ssdp_udn mock_igd_device.device = mock_upnp_device @@ -179,7 +188,7 @@ async def ssdp_instant_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -212,7 +221,7 @@ async def ssdp_instant_discovery_multi_location(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: @@ -241,7 +250,7 @@ async def ssdp_no_discovery(): async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 087cd9e9fb4..d9b5b442b00 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -6,7 +6,7 @@ from async_upnp_client.profiles.igd import IgdDevice, IgdState from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 8799e0faab3..fb650ac7a47 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -7,7 +7,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.upnp.const import ( CONFIG_ENTRY_FORCE_POLL, CONFIG_ENTRY_HOST, @@ -21,6 +20,11 @@ from homeassistant.components.upnp.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_DEVICE_TYPE, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from .conftest import ( TEST_DISCOVERY, @@ -109,14 +113,14 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, # ssdp_udn=TEST_UDN, # Not provided. ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. + ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, + # ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ), ) @@ -130,14 +134,14 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn=TEST_USN, ssdp_udn=TEST_UDN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, ssdp_all_locations=[TEST_LOCATION], upnp={ - ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD }, ), ) @@ -449,7 +453,7 @@ async def test_flow_ssdp_with_mismatched_udn(hass: HomeAssistant) -> None: """Test config flow: discovered + configured through ssdp, where the UDN differs in the SSDP-discovery vs device description.""" # Discovered via step ssdp. test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index ff74ca87b12..ef799a1b8af 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -22,6 +22,7 @@ from homeassistant.components.upnp.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from .conftest import ( TEST_DISCOVERY, @@ -125,7 +126,7 @@ async def test_async_setup_udn_mismatch( ) -> None: """Test async_setup_entry for a device which reports a different UDN from SSDP-discovery and device description.""" test_discovery = copy.deepcopy(TEST_DISCOVERY) - test_discovery.upnp[ssdp.ATTR_UPNP_UDN] = "uuid:another_udn" + test_discovery.upnp[ATTR_UPNP_UDN] = "uuid:another_udn" entry = MockConfigEntry( domain=DOMAIN, @@ -146,7 +147,7 @@ async def test_async_setup_udn_mismatch( async def register_callback( hass: HomeAssistant, callback: Callable[ - [ssdp.SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None + [SsdpServiceInfo, ssdp.SsdpChange], Coroutine[Any, Any, None] | None ], match_dict: dict[str, str] | None = None, ) -> MagicMock: diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index e9d8a9cce8f..e7461c91da4 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index 38312667375..93b1da60998 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -27,10 +27,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Uptime', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Uptime', 'type': , 'version': 1, diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 561e4b83320..d6d896dbcec 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index bbd802afc95..9730dba53d7 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,70 +1,44 @@ """Tests for the USB Discovery integration.""" +import asyncio +from datetime import timedelta +import logging import os -import sys +from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel +from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb +from homeassistant.components.usb.utils import usb_device_from_port from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import conbee_device, slae_sh_device +from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator -@pytest.fixture(name="operating_system") -def mock_operating_system(): - """Mock running Home Assistant Operating system.""" +@pytest.fixture(name="aiousbwatcher_no_inotify") +def aiousbwatcher_no_inotify(): + """Patch AIOUSBWatcher to not use inotify.""" with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": True, - "docker": True, - }, + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, ): yield -@pytest.fixture(name="docker") -def mock_docker(): - """Mock running Home Assistant in docker container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": True, - }, - ): - yield - - -@pytest.fixture(name="venv") -def mock_venv(): - """Mock running Home Assistant in a venv container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": False, - "virtualenv": True, - }, - ): - yield - - -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) -async def test_observer_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer can discover a device without raising an exception.""" - new_usb = [{"domain": "test1", "vid": "3039"}] + """Test that aiousbwatcher can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] mock_comports = [ MagicMock( @@ -76,25 +50,23 @@ async def test_observer_discovery( description=slae_sh_device.description, ) ] - mock_observer = None - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="create", device_path="/dev/new") - ) + aiousbwatcher_callback = None - def _create_mock_monitor_observer(monitor, callback, name): - nonlocal mock_observer - hass.create_task(_mock_monitor_observer_callback(callback)) - mock_observer = MagicMock() - return mock_observer + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( - patch("pyudev.Context"), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), - patch("pyudev.Monitor.filter_by"), + patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -102,33 +74,98 @@ async def test_observer_discovery( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + assert aiousbwatcher_callback is not None + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 + + mock_comports.append( + MagicMock( + device=slae_sh_device.device, + vid=4000, + pid=4000, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ) + + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "test2" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +async def test_polling_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that polling can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}] + mock_comports_found_device = asyncio.Event() + + def get_comports() -> list: + nonlocal mock_comports + + # Only "find" a device after a few invocations + if len(mock_comports.mock_calls) < 5: + return [] + + mock_comports_found_device.set() + return [ + MagicMock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ] + + with ( + patch("sys.platform", "linux"), + patch( + "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", + timedelta(seconds=0.01), + ), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch( + "homeassistant.components.usb.comports", side_effect=get_comports + ) as mock_comports, + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Wait until a new device is discovered after a few polling attempts + assert len(mock_config_flow.mock_calls) == 0 + await mock_comports_found_device.wait() + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "test1" hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - # pylint:disable-next=unnecessary-dunder-call - assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] - - -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) -async def test_removal_by_observer_before_started( - hass: HomeAssistant, operating_system -) -> None: - """Test a device is removed by the observer before started.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="remove", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -145,7 +182,6 @@ async def test_removal_by_observer_before_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -161,6 +197,7 @@ async def test_removal_by_observer_before_started( await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -179,7 +216,6 @@ async def test_discovered_by_websocket_scan( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -198,6 +234,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -218,7 +255,6 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -237,6 +273,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -258,7 +295,6 @@ async def test_most_targeted_matcher_wins( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -277,6 +313,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -297,7 +334,6 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -315,6 +351,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -340,7 +377,6 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -359,6 +395,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -379,7 +416,6 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -397,6 +433,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -422,7 +459,6 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -441,6 +477,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -466,7 +503,6 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -484,6 +520,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -504,7 +541,6 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -522,6 +558,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -540,7 +577,6 @@ async def test_discovered_by_websocket_scan_match_vid_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -559,6 +595,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -577,7 +614,6 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -595,6 +631,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -613,7 +650,6 @@ async def test_discovered_by_websocket_no_vid_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -631,9 +667,9 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.parametrize("exception_type", [ImportError, OSError]) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_non_matching_discovered_by_scanner_after_started( - hass: HomeAssistant, exception_type, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] @@ -650,7 +686,6 @@ async def test_non_matching_discovered_by_scanner_after_started( ] with ( - patch("pyudev.Context", side_effect=exception_type), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -668,14 +703,10 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) -async def test_observer_on_wsl_fallback_without_throwing_exception( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports = [ @@ -690,8 +721,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( ] with ( - patch("pyudev.Context"), - patch("pyudev.Monitor.filter_by", side_effect=ValueError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -710,24 +739,8 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -@pytest.mark.skipif( - not sys.platform.startswith("linux"), - reason="Only works on linux", -) -async def test_not_discovered_by_observer_before_started_on_docker( - hass: HomeAssistant, docker -) -> None: - """Test a device is not discovered since observer is not running on bare docker.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - return MagicMock() - +async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -740,23 +753,45 @@ async def test_not_discovered_by_observer_before_started_on_docker( description=slae_sh_device.description, ) ] + initial_mock_comports = [] + aiousbwatcher_callback = None + + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( + patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), + patch( + "homeassistant.components.usb.comports", return_value=initial_mock_comports + ), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() - with ( - patch("homeassistant.components.usb.comports", return_value=[]), - patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, - ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 0 + assert len(mock_config_flow.mock_calls) == 0 + + initial_mock_comports.extend(mock_comports) + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 def test_get_serial_by_id_no_dir() -> None: @@ -839,6 +874,7 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -862,7 +898,6 @@ async def test_async_is_plugged_in( } with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -885,6 +920,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "matcher", [ @@ -903,7 +939,6 @@ async def test_async_is_plugged_in_case_enforcement( new_usb = [{"domain": "test1", "vid": "ABCD"}] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -917,6 +952,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -924,7 +960,6 @@ async def test_web_socket_triggers_discovery_request_callbacks( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -952,6 +987,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -960,7 +996,6 @@ async def test_initial_scan_callback( mock_callback_2 = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -988,6 +1023,7 @@ async def test_initial_scan_callback( cancel_2() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -995,7 +1031,6 @@ async def test_cancel_initial_scan_callback( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1014,6 +1049,7 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1032,7 +1068,6 @@ async def test_resolve_serial_by_id( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch( @@ -1056,6 +1091,7 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "ports", [ @@ -1140,7 +1176,6 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -1160,3 +1195,192 @@ async def test_cp2102n_ordering_on_macos( # We always use `cu.SLAB_USBtoUART` assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/cu.SLAB_USBtoUART2" + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "UsbServiceInfo", + "homeassistant.helpers.service_info.usb.UsbServiceInfo", + UsbServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + usb, + constant_name, + replacement_name, + replacement, + "2026.2", + ) + + +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the registration of a port event callback.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock() + mock_callback2 = Mock() + + # Start off with no ports + with ( + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + _cancel1 = usb.async_register_port_event_callback(hass, mock_callback1) + cancel2 = usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with patch("homeassistant.components.usb.comports", return_value=[port1, port2]): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + # Cancel the second callback + cancel2() + cancel2() + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Remove port 2 + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 2, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call(set(), {port2_usb})] + assert mock_callback2.mock_calls == [] # The second callback was unregistered + + mock_callback1.reset_mock() + mock_callback2.reset_mock() + + # Keep port 2 removed + with patch("homeassistant.components.usb.comports", return_value=[port1]): + await ws_client.send_json({"id": 3, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Nothing changed so no callback is called + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Unplug one and plug in the other + with patch("homeassistant.components.usb.comports", return_value=[port2]): + await ws_client.send_json({"id": 4, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert mock_callback1.mock_calls == [call({port2_usb}, {port1_usb})] + assert mock_callback2.mock_calls == [] + + +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +@patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) +async def test_register_port_event_callback_failure( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test port event callback failure handling.""" + + port1 = Mock( + device=slae_sh_device.device, + vid=12345, + pid=12345, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + + port2 = Mock( + device=conbee_device.device, + vid=12346, + pid=12346, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ) + + port1_usb = usb_device_from_port(port1) + port2_usb = usb_device_from_port(port2) + + ws_client = await hass_ws_client(hass) + + mock_callback1 = Mock(side_effect=RuntimeError("Failure 1")) + mock_callback2 = Mock(side_effect=RuntimeError("Failure 2")) + + # Start off with no ports + with ( + patch("homeassistant.components.usb.comports", return_value=[]), + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + + usb.async_register_port_event_callback(hass, mock_callback1) + usb.async_register_port_event_callback(hass, mock_callback2) + + assert mock_callback1.mock_calls == [] + assert mock_callback2.mock_calls == [] + + # Add two new ports + with ( + patch("homeassistant.components.usb.comports", return_value=[port1, port2]), + caplog.at_level(logging.ERROR, logger="homeassistant.components.usb"), + ): + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + # Both were called even though they raised exceptions + assert mock_callback1.mock_calls == [call({port1_usb, port2_usb}, set())] + assert mock_callback2.mock_calls == [call({port1_usb, port2_usb}, set())] + + assert caplog.text.count("Error in USB port event callback") == 2 + assert "Failure 1" in caplog.text + assert "Failure 2" in caplog.text diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index 40d19422ced..e412d53a0d0 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index 6cdf121d7e3..ef235bba99d 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Energy Bill', 'unique_id': None, 'version': 2, diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 560566d7c49..4901e069aee 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -374,6 +374,7 @@ async def test_change_device_source( # Configure source entity 3 (without a device) source_config_entry_3 = MockConfigEntry() + source_config_entry_3.add_to_hass(hass) source_entity_3 = entity_registry.async_get_or_create( "sensor", "test", diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index cd549c77913..eba7cf913db 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -12,9 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.utility_meter import ( + select as um_select, + sensor as um_sensor, +) from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -import homeassistant.components.utility_meter.select as um_select -import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -26,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, mock_restore_cache diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 348afac57f7..c671969c5ac 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -44,7 +44,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 96567b80c54..780a00acd64 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'ABC123', 'version': 1, diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 7b9ae4a9ff3..46054b21324 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -312,6 +318,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -396,6 +403,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -482,6 +490,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,6 +542,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -580,6 +590,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vacuum/conftest.py b/tests/components/vacuum/conftest.py index 6e6639431d0..2c700daece0 100644 --- a/tests/components/vacuum/conftest.py +++ b/tests/components/vacuum/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntit from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, frame -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MockVacuum @@ -87,7 +87,7 @@ async def setup_vacuum_platform_test_entity( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test vacuum platform via config entry.""" async_add_entities([entity]) diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 3a0cbafb4a1..381cc1caa47 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 8babd9fa265..8ae054b5646 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -33,6 +33,7 @@ from .common import async_start from tests.common import ( MockConfigEntry, MockEntity, + MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -288,6 +289,8 @@ async def test_supported_features_compat(hass: HomeAssistant) -> None: _attr_fan_speed_list = ["silent", "normal", "pet hair"] entity = _LegacyConstantsStateVacuum() + entity.hass = hass + entity.platform = MockEntityPlatform(hass) assert isinstance(entity.supported_features, int) assert entity.supported_features == int(features) assert entity.supported_features_compat is ( diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index d8eb38a3b9b..a26f88f6982 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -22,7 +22,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -174,7 +174,7 @@ def mock_config_entry(hass: HomeAssistant) -> tuple[MockConfigEntry, list[ValveE async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 95f691b34f8..65418790280 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from velbusaio.channels import ( @@ -72,6 +72,7 @@ def mock_controller( 4: mock_module_no_subdevices, 99: mock_module_subdevices, } + cont.get_module.return_value = mock_module_subdevices yield controller @@ -95,7 +96,7 @@ def mock_module_subdevices() -> AsyncMock: """Mock a velbus module.""" module = AsyncMock(spec=Module) module.get_type_name.return_value = "VMB2BLE" - module.get_addresses.return_value = [99] + module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" module.get_sw_version.return_value = "2.0.0" module.is_loaded.return_value = True @@ -112,9 +113,11 @@ def mock_button() -> AsyncMock: channel.get_module_address.return_value = 1 channel.get_channel_number.return_value = 1 channel.get_module_type_name.return_value = "VMB4RYLD" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 99 + channel.get_full_name.return_value = "Bedroom kid 1" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_closed.return_value = True channel.is_on.return_value = False return channel @@ -129,9 +132,11 @@ def mock_temperature() -> AsyncMock: channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB4GPO" - channel.get_full_name.return_value = "Channel full name" + channel.get_full_name.return_value = "Living room" channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_serial.return_value = "asdfghjk" + channel.get_module_type.return_value = 1 + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.get_class.return_value = "temperature" channel.get_unit.return_value = "°C" @@ -152,12 +157,14 @@ def mock_relay() -> AsyncMock: channel = AsyncMock(spec=Relay) channel.get_categories.return_value = ["switch"] channel.get_name.return_value = "RelayName" - channel.get_module_address.return_value = 99 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 55 channel.get_module_type_name.return_value = "VMB4RYNO" - channel.get_full_name.return_value = "Full relay name" + channel.get_full_name.return_value = "Living room" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "qwerty123" + channel.get_module_type.return_value = 2 + channel.is_sub_device.return_value = True channel.is_on.return_value = True return channel @@ -168,12 +175,14 @@ def mock_select() -> AsyncMock: channel = AsyncMock(spec=SelectedProgram) channel.get_categories.return_value = ["select"] channel.get_name.return_value = "select" - channel.get_module_address.return_value = 55 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 33 channel.get_module_type_name.return_value = "VMB4RYNO" - channel.get_full_name.return_value = "Full module name" + channel.get_module_type.return_value = 3 + channel.get_full_name.return_value = "Kitchen" channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_serial.return_value = "qwerty1234567" + channel.is_sub_device.return_value = False channel.get_options.return_value = ["none", "summer", "winter", "holiday"] channel.get_selected_program.return_value = "winter" return channel @@ -185,12 +194,14 @@ def mock_buttoncounter() -> AsyncMock: channel = AsyncMock(spec=ButtonCounter) channel.get_categories.return_value = ["sensor"] channel.get_name.return_value = "ButtonCounter" - channel.get_module_address.return_value = 2 + channel.get_module_address.return_value = 88 channel.get_channel_number.return_value = 2 channel.get_module_type_name.return_value = "VMB7IN" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 4 + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = True channel.is_counter_channel.return_value = True channel.is_temperature.return_value = False channel.get_state.return_value = 100 @@ -209,9 +220,11 @@ def mock_sensornumber() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 3 channel.get_module_type_name.return_value = "VMB7IN" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 8 + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "m" @@ -228,9 +241,11 @@ def mock_lightsensor() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 4 channel.get_module_type_name.return_value = "VMB7IN" - channel.get_full_name.return_value = "Channel full name" + channel.get_module_type.return_value = 8 + channel.get_full_name.return_value = "Input" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "illuminance" @@ -244,12 +259,14 @@ def mock_dimmer() -> AsyncMock: channel = AsyncMock(spec=Dimmer) channel.get_categories.return_value = ["light"] channel.get_name.return_value = "Dimmer" - channel.get_module_address.return_value = 3 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 10 channel.get_module_type_name.return_value = "VMBDN1" + channel.get_module_type.return_value = 9 channel.get_full_name.return_value = "Dimmer full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6g7" + channel.is_sub_device.return_value = True channel.is_on.return_value = False channel.get_dimmer_state.return_value = 33 return channel @@ -261,12 +278,14 @@ def mock_cover() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverName" - channel.get_module_address.return_value = 201 - channel.get_channel_number.return_value = 2 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 9 channel.get_module_type_name.return_value = "VMB2BLE" - channel.get_full_name.return_value = "Full cover name" + channel.get_module_type.return_value = 10 + channel.get_full_name.return_value = "Basement" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "1234" + channel.is_sub_device.return_value = True channel.support_position.return_value = True channel.get_position.return_value = 50 channel.is_closed.return_value = False @@ -282,12 +301,14 @@ def mock_cover_no_position() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverNameNoPos" - channel.get_module_address.return_value = 200 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 11 channel.get_module_type_name.return_value = "VMB2BLE" - channel.get_full_name.return_value = "Full cover name no position" + channel.get_module_type.return_value = 10 + channel.get_full_name.return_value = "Basement" channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "12345" + channel.is_sub_device.return_value = True channel.support_position.return_value = False channel.get_position.return_value = None channel.is_closed.return_value = False @@ -300,7 +321,7 @@ def mock_cover_no_position() -> AsyncMock: @pytest.fixture(name="config_entry") async def mock_config_entry( hass: HomeAssistant, - controller: MagicMock, + controller: AsyncMock, ) -> VelbusConfigEntry: """Create and register mock config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/velbus/snapshots/test_binary_sensor.ambr b/tests/components/velbus/snapshots/test_binary_sensor.ambr index 998f2528d9c..70db53257a1 100644 --- a/tests/components/velbus/snapshots/test_binary_sensor.ambr +++ b/tests/components/velbus/snapshots/test_binary_sensor.ambr @@ -1,18 +1,19 @@ # serializer version: 1 -# name: test_entities[binary_sensor.buttonon-entry] +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.buttonon', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +33,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[binary_sensor.buttonon-state] +# name: test_entities[binary_sensor.bedroom_kid_1_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'ButtonOn', + 'friendly_name': 'Bedroom kid 1 ButtonOn', }), 'context': , - 'entity_id': 'binary_sensor.buttonon', + 'entity_id': 'binary_sensor.bedroom_kid_1_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_button.ambr b/tests/components/velbus/snapshots/test_button.ambr index afe79466a44..856ebdb1e21 100644 --- a/tests/components/velbus/snapshots/test_button.ambr +++ b/tests/components/velbus/snapshots/test_button.ambr @@ -1,18 +1,19 @@ # serializer version: 1 -# name: test_entities[button.buttonon-entry] +# name: test_entities[button.bedroom_kid_1_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.buttonon', - 'has_entity_name': False, + 'entity_id': 'button.bedroom_kid_1_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +33,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[button.buttonon-state] +# name: test_entities[button.bedroom_kid_1_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'ButtonOn', + 'friendly_name': 'Bedroom kid 1 ButtonOn', }), 'context': , - 'entity_id': 'button.buttonon', + 'entity_id': 'button.bedroom_kid_1_buttonon', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_climate.ambr b/tests/components/velbus/snapshots/test_climate.ambr index 567e45d9299..1d1f49d14d9 100644 --- a/tests/components/velbus/snapshots/test_climate.ambr +++ b/tests/components/velbus/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[climate.temperature-entry] +# name: test_entities[climate.living_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -19,13 +19,14 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.temperature', - 'has_entity_name': False, + 'entity_id': 'climate.living_room_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -45,11 +46,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[climate.temperature-state] +# name: test_entities[climate.living_room_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 20.0, - 'friendly_name': 'Temperature', + 'friendly_name': 'Living room Temperature', 'hvac_modes': list([ , , @@ -67,7 +68,7 @@ 'temperature': 21.0, }), 'context': , - 'entity_id': 'climate.temperature', + 'entity_id': 'climate.living_room_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index eb41839078d..0be18034bc0 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -1,18 +1,19 @@ # serializer version: 1 -# name: test_entities[cover.covername-entry] +# name: test_entities[cover.basement_covername-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.covername', - 'has_entity_name': False, + 'entity_id': 'cover.basement_covername', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -28,39 +29,40 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1234-2', + 'unique_id': '1234-9', 'unit_of_measurement': None, }) # --- -# name: test_entities[cover.covername-state] +# name: test_entities[cover.basement_covername-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 50, - 'friendly_name': 'CoverName', + 'friendly_name': 'Basement CoverName', 'supported_features': , }), 'context': , - 'entity_id': 'cover.covername', + 'entity_id': 'cover.basement_covername', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'open', }) # --- -# name: test_entities[cover.covernamenopos-entry] +# name: test_entities[cover.basement_covernamenopos-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'cover', 'entity_category': None, - 'entity_id': 'cover.covernamenopos', - 'has_entity_name': False, + 'entity_id': 'cover.basement_covernamenopos', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -76,19 +78,19 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '12345-1', + 'unique_id': '12345-11', 'unit_of_measurement': None, }) # --- -# name: test_entities[cover.covernamenopos-state] +# name: test_entities[cover.basement_covernamenopos-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'assumed_state': True, - 'friendly_name': 'CoverNameNoPos', + 'friendly_name': 'Basement CoverNameNoPos', 'supported_features': , }), 'context': , - 'entity_id': 'cover.covernamenopos', + 'entity_id': 'cover.basement_covernamenopos', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index 3359cb78590..a280bf4c9c2 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -10,12 +10,14 @@ 'discovery_keys': dict({ }), 'domain': 'velbus', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 2, @@ -79,7 +81,7 @@ }), dict({ 'address': list([ - 99, + 88, ]), 'channels': dict({ }), diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr new file mode 100644 index 00000000000..1e17753a02f --- /dev/null +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -0,0 +1,253 @@ +# serializer version: 1 +# name: test_device_registry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYLD', + 'model_id': '99', + 'name': 'Bedroom kid 1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-11', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '12345', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-10', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMBDN1', + 'model_id': '9', + 'name': 'Dimmer full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '4', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4GPO', + 'model_id': '1', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'asdfghjk', + 'suggested_area': None, + 'sw_version': '3.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '8', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-55', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB4RYNO', + 'model_id': '2', + 'name': 'Living room', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'qwerty123', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index a4574f1b339..6dd2ca4939d 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -1,61 +1,5 @@ # serializer version: 1 -# name: test_entities[light.dimmer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'supported_color_modes': list([ - , - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.dimmer', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dimmer', - 'platform': 'velbus', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6g7-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[light.dimmer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Dimmer', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.dimmer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_entities[light.led_buttonon-entry] +# name: test_entities[light.bedroom_kid_1_led_buttonon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -66,13 +10,14 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'light', 'entity_category': , - 'entity_id': 'light.led_buttonon', - 'has_entity_name': False, + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -92,18 +37,75 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[light.led_buttonon-state] +# name: test_entities[light.bedroom_kid_1_led_buttonon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': None, - 'friendly_name': 'LED ButtonOn', + 'friendly_name': 'Bedroom kid 1 LED ButtonOn', 'supported_color_modes': list([ , ]), 'supported_features': , }), 'context': , - 'entity_id': 'light.led_buttonon', + 'entity_id': 'light.bedroom_kid_1_led_buttonon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[light.dimmer_full_name_dimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_full_name_dimmer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmer', + 'platform': 'velbus', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6g7-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.dimmer_full_name_dimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer full name Dimmer', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_full_name_dimmer', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr index 5678c0ded5f..94bb109fc71 100644 --- a/tests/components/velbus/snapshots/test_select.ambr +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[select.select-entry] +# name: test_entities[select.kitchen_select-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,13 +13,14 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.select', - 'has_entity_name': False, + 'entity_id': 'select.kitchen_select', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -39,10 +40,10 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[select.select-state] +# name: test_entities[select.kitchen_select-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'select', + 'friendly_name': 'Kitchen select', 'options': list([ 'none', 'summer', @@ -51,7 +52,7 @@ ]), }), 'context': , - 'entity_id': 'select.select', + 'entity_id': 'select.kitchen_select', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr index 132f4c7a059..6f562f399af 100644 --- a/tests/components/velbus/snapshots/test_sensor.ambr +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[sensor.buttoncounter-entry] +# name: test_entities[sensor.input_buttoncounter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -8,13 +8,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.buttoncounter', - 'has_entity_name': False, + 'entity_id': 'sensor.input_buttoncounter', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -34,23 +35,23 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_entities[sensor.buttoncounter-state] +# name: test_entities[sensor.input_buttoncounter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'ButtonCounter', + 'friendly_name': 'Input ButtonCounter', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.buttoncounter', + 'entity_id': 'sensor.input_buttoncounter', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_entities[sensor.buttoncounter_counter-entry] +# name: test_entities[sensor.input_buttoncounter_counter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,13 +60,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.buttoncounter_counter', - 'has_entity_name': False, + 'entity_id': 'sensor.input_buttoncounter_counter', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -85,24 +87,24 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_entities[sensor.buttoncounter_counter-state] +# name: test_entities[sensor.input_buttoncounter_counter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'ButtonCounter-counter', + 'friendly_name': 'Input ButtonCounter-counter', 'icon': 'mdi:counter', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.buttoncounter_counter', + 'entity_id': 'sensor.input_buttoncounter_counter', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_entities[sensor.lightsensor-entry] +# name: test_entities[sensor.input_lightsensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -111,13 +113,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.lightsensor', - 'has_entity_name': False, + 'entity_id': 'sensor.input_lightsensor', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -137,22 +140,22 @@ 'unit_of_measurement': 'illuminance', }) # --- -# name: test_entities[sensor.lightsensor-state] +# name: test_entities[sensor.input_lightsensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'LightSensor', + 'friendly_name': 'Input LightSensor', 'state_class': , 'unit_of_measurement': 'illuminance', }), 'context': , - 'entity_id': 'sensor.lightsensor', + 'entity_id': 'sensor.input_lightsensor', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '250.0', }) # --- -# name: test_entities[sensor.sensornumber-entry] +# name: test_entities[sensor.input_sensornumber-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -161,13 +164,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.sensornumber', - 'has_entity_name': False, + 'entity_id': 'sensor.input_sensornumber', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -187,22 +191,22 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_entities[sensor.sensornumber-state] +# name: test_entities[sensor.input_sensornumber-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'SensorNumber', + 'friendly_name': 'Input SensorNumber', 'state_class': , 'unit_of_measurement': 'm', }), 'context': , - 'entity_id': 'sensor.sensornumber', + 'entity_id': 'sensor.input_sensornumber', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '10.0', }) # --- -# name: test_entities[sensor.temperature-entry] +# name: test_entities[sensor.living_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -211,13 +215,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.temperature', - 'has_entity_name': False, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -237,16 +242,16 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.temperature-state] +# name: test_entities[sensor.living_room_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Temperature', + 'friendly_name': 'Living room Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.temperature', + 'entity_id': 'sensor.living_room_temperature', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr index 87f0f7eac02..60458b196a8 100644 --- a/tests/components/velbus/snapshots/test_switch.ambr +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -1,18 +1,19 @@ # serializer version: 1 -# name: test_entities[switch.relayname-entry] +# name: test_entities[switch.living_room_relayname-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.relayname', - 'has_entity_name': False, + 'entity_id': 'switch.living_room_relayname', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,13 +33,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[switch.relayname-state] +# name: test_entities[switch.living_room_relayname-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'RelayName', + 'friendly_name': 'Living room RelayName', }), 'context': , - 'entity_id': 'switch.relayname', + 'entity_id': 'switch.living_room_relayname', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/velbus/test_button.py b/tests/components/velbus/test_button.py index d1079497879..cf334a29b05 100644 --- a/tests/components/velbus/test_button.py +++ b/tests/components/velbus/test_button.py @@ -38,6 +38,9 @@ async def test_button_press( """Test button press.""" await init_integration(hass, config_entry) await hass.services.async_call( - BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: "button.buttonon"}, blocking=True + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.bedroom_kid_1_buttonon"}, + blocking=True, ) mock_button.press.assert_called_once_with() diff --git a/tests/components/velbus/test_climate.py b/tests/components/velbus/test_climate.py index fd0a268bb0f..c843bca6e68 100644 --- a/tests/components/velbus/test_climate.py +++ b/tests/components/velbus/test_climate.py @@ -51,7 +51,7 @@ async def test_set_target_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_TEMPERATURE: 29}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_TEMPERATURE: 29}, blocking=True, ) mock_temperature.set_temp.assert_called_once_with(29) @@ -78,7 +78,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_PRESET_MODE: set_mode}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_PRESET_MODE: set_mode}, blocking=True, ) mock_temperature.set_preset.assert_called_once_with(expected_mode) @@ -102,7 +102,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_HVAC_MODE: set_mode}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: set_mode}, blocking=True, ) mock_temperature.set_mode.assert_called_once_with(set_mode) @@ -119,7 +119,7 @@ async def test_set_hvac_mode_invalid( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.temperature", ATTR_HVAC_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.living_room_temperature", ATTR_HVAC_MODE: "auto"}, blocking=True, ) mock_temperature.set_mode.assert_not_called() diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 5e81a3f8a36..ee714624b45 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,18 +7,18 @@ import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components import usb -from homeassistant.components.velbus.const import DOMAIN +from homeassistant.components.velbus.const import CONF_TLS, DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER -from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import PORT_SERIAL, PORT_TCP +from .const import PORT_SERIAL from tests.common import MockConfigEntry -DISCOVERY_INFO = usb.UsbServiceInfo( +DISCOVERY_INFO = UsbServiceInfo( device=PORT_SERIAL, pid="10CF", vid="0B1B", @@ -27,6 +27,8 @@ DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="Velleman", ) +USB_DEV = "/dev/ttyACME100 - Some serial port, s/n: 1234 - Virtual serial port" + def com_port(): """Mock of a serial port.""" @@ -38,23 +40,15 @@ def com_port(): return port -@pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock]: - """Mock a successful velbus controller.""" - with patch( - "homeassistant.components.velbus.config_flow.velbusaio.controller.Velbus", - autospec=True, - ) as controller: - yield controller - - @pytest.fixture(autouse=True) def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" - with patch( - "homeassistant.components.velbus.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry + with ( + patch( + "homeassistant.components.velbus.async_setup_entry", return_value=True + ) as mock, + ): + yield mock @pytest.fixture(name="controller_connection_failed") @@ -65,73 +59,126 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user(hass: HomeAssistant) -> None: - """Test user config.""" - # simple user form +async def test_user_network_succes(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result assert result.get("flow_id") - assert result.get("type") is FlowResultType.FORM + assert result.get("type") is FlowResultType.MENU assert result.get("step_id") == "user" - - # try with a serial port - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result.get("type") is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_serial" + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "velbus:6000" + + +@pytest.mark.usefixtures("controller") +async def test_user_network_succes_tls(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result + assert result.get("flow_id") + assert result.get("type") is FlowResultType.MENU + assert result.get("step_id") == "user" + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result["type"] is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: True, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "password", + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "tls://password@velbus:6000" + + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_user_usb_succes(hass: HomeAssistant) -> None: + """Test user usb step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus USB" data = result.get("data") assert data assert data[CONF_PORT] == PORT_SERIAL - # try with a ip:port combination - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_tcp" - data = result.get("data") - assert data - assert data[CONF_PORT] == PORT_TCP - -@pytest.mark.usefixtures("controller_connection_failed") -async def test_user_fail(hass: HomeAssistant) -> None: - """Test user config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - -@pytest.mark.usefixtures("config_entry") -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("controller") +async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus is already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "127.0.0.1:3788"}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus test"}, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TLS: False, + CONF_HOST: "127.0.0.1", + CONF_PORT: 3788, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.ABORT @@ -156,7 +203,7 @@ async def test_flow_usb(hass: HomeAssistant) -> None: user_input={}, ) assert result - assert result["result"].unique_id == "0B1B:10CF_1234_Velleman_Velbus VMB1USB" + assert result["result"].unique_id == "1234" assert result.get("type") is FlowResultType.CREATE_ENTRY @@ -167,13 +214,23 @@ async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={CONF_PORT: PORT_SERIAL}, - unique_id="0B1B:10CF_1234_Velleman_Velbus VMB1USB", ) entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USB}, - data=DISCOVERY_INFO, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, ) assert result assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/velbus/test_cover.py b/tests/components/velbus/test_cover.py index fe3fbbe1594..24a90e0f8d1 100644 --- a/tests/components/velbus/test_cover.py +++ b/tests/components/velbus/test_cover.py @@ -38,8 +38,8 @@ async def test_entities( @pytest.mark.parametrize( ("entity_id", "entity_num"), [ - ("cover.covername", 0), - ("cover.covernamenopos", 1), + ("cover.basement_covername", 0), + ("cover.basement_covernamenopos", 1), ], ) async def test_actions( @@ -84,7 +84,7 @@ async def test_position( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.covername", ATTR_POSITION: 25}, + {ATTR_ENTITY_ID: "cover.basement_covername", ATTR_POSITION: 25}, blocking=True, ) mock_cover.set_position.assert_called_once_with(75) diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index b7c334d7814..2d28ba81cb1 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -1,17 +1,22 @@ """Tests for the Velbus component initialisation.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from syrupy.assertion import SnapshotAssertion from velbusaio.exceptions import VelbusConnectionFailed +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration +from .const import PORT_TCP from tests.common import MockConfigEntry @@ -103,13 +108,81 @@ async def test_migrate_config_entry( """Test successful migration of entry data.""" legacy_config = {CONF_NAME: "fake_name", CONF_PORT: "1.2.3.4:5678"} entry = MockConfigEntry(domain=DOMAIN, unique_id="my own id", data=legacy_config) - entry.add_to_hass(hass) - - assert dict(entry.data) == legacy_config assert entry.version == 1 + assert entry.minor_version == 1 + + entry.add_to_hass(hass) # test in case we do not have a cache with patch("os.path.isdir", return_value=True), patch("shutil.rmtree"): await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + assert entry.minor_version == 2 + + +@pytest.mark.parametrize( + ("unique_id", "expected"), + [("vid:pid_serial_manufacturer_decription", "serial"), (None, None)], +) +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + controller: AsyncMock, + unique_id: str, + expected: str, +) -> None: + """Test the migration of unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus home"}, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.unique_id == expected + assert entry.version == 2 + assert entry.minor_version == 2 + + +async def test_api_call( + hass: HomeAssistant, + mock_relay: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test the api call decorator action.""" + await init_integration(hass, config_entry) + + mock_relay.turn_on.side_effect = OSError() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, + blocking=True, + ) + + +async def test_device_registry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the velbus device registry.""" + await init_integration(hass, config_entry) + + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot + + device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")}) + assert device_parent.via_device_id is None + + device = device_registry.async_get_device(identifiers={(DOMAIN, "88-9")}) + assert device.via_device_id == device_parent.id + + device_no_sub = device_registry.async_get_device(identifiers={(DOMAIN, "2")}) + assert device_no_sub.via_device_id is None diff --git a/tests/components/velbus/test_light.py b/tests/components/velbus/test_light.py index 344d1626bbd..0ce93d6e6bb 100644 --- a/tests/components/velbus/test_light.py +++ b/tests/components/velbus/test_light.py @@ -52,7 +52,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.dimmer"}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer"}, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_once_with(0, 0) @@ -60,7 +60,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_TRANSITION: 1}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_TRANSITION: 1}, blocking=True, ) mock_dimmer.restore_dimmer_state.assert_called_once_with(1) @@ -68,7 +68,11 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 1}, + { + ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", + ATTR_BRIGHTNESS: 0, + ATTR_TRANSITION: 1, + }, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_with(0, 1) @@ -77,7 +81,7 @@ async def test_dimmer_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.dimmer", ATTR_BRIGHTNESS: 33}, + {ATTR_ENTITY_ID: "light.dimmer_full_name_dimmer", ATTR_BRIGHTNESS: 33}, blocking=True, ) mock_dimmer.set_dimmer_state.assert_called_with(12, 0) @@ -96,7 +100,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.led_buttonon"}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, blocking=True, ) mock_button.set_led_state.assert_called_once_with("off") @@ -104,7 +108,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon"}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon"}, blocking=True, ) mock_button.set_led_state.assert_called_with("on") @@ -113,7 +117,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_LONG}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_LONG}, blocking=True, ) mock_button.set_led_state.assert_called_with("slow") @@ -122,7 +126,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_button.set_led_state.assert_called_with("fast") @@ -131,7 +135,7 @@ async def test_led_actions( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.led_buttonon", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.bedroom_kid_1_led_buttonon", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_button.set_led_state.assert_called_with("fast") diff --git a/tests/components/velbus/test_select.py b/tests/components/velbus/test_select.py index 64ac2c98009..782ae53d440 100644 --- a/tests/components/velbus/test_select.py +++ b/tests/components/velbus/test_select.py @@ -46,7 +46,7 @@ async def test_select_program( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: "select.select", ATTR_OPTION: set_program}, + {ATTR_ENTITY_ID: "select.kitchen_select", ATTR_OPTION: set_program}, blocking=True, ) mock_select.set_selected_program.assert_called_once_with(set_program) diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py new file mode 100644 index 00000000000..94ba91e6dc3 --- /dev/null +++ b/tests/components/velbus/test_services.py @@ -0,0 +1,172 @@ +"""Velbus services tests.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.velbus.const import ( + CONF_CONFIG_ENTRY, + CONF_INTERFACE, + CONF_MEMO_TEXT, + DOMAIN, + SERVICE_CLEAR_CACHE, + SERVICE_SCAN, + SERVICE_SET_MEMO_TEXT, + SERVICE_SYNC, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import issue_registry as ir + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_global_services_with_interface( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test services directed at the bus with an interface parameter.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_INTERFACE: config_entry.data["port"]}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_INTERFACE: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_global_survices_with_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test services directed at the bus with a config_entry.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.sync_clock.assert_called_once_with() + + # Test invalid interface + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: "nonexistent"}, + blocking=True, + ) + + # Test missing interface + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {}, + blocking=True, + ) + + +async def test_set_memo_text( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: AsyncMock, +) -> None: + """Test the set_memo_text service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 1, + }, + blocking=True, + ) + config_entry.runtime_data.controller.get_module( + 1 + ).set_memo_text.assert_called_once_with("Test") + + # Test with unfound module + controller.return_value.get_module.return_value = None + with pytest.raises(ServiceValidationError, match="Module not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 2, + }, + blocking=True, + ) + + +async def test_clear_cache( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test the clear_cache service.""" + await init_integration(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + config_entry.runtime_data.controller.scan.assert_called_once_with() + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + {CONF_CONFIG_ENTRY: config_entry.entry_id, CONF_ADDRESS: 1}, + blocking=True, + ) + assert config_entry.runtime_data.controller.scan.call_count == 2 diff --git a/tests/components/velbus/test_switch.py b/tests/components/velbus/test_switch.py index 9efb65af68d..ebb1da084c4 100644 --- a/tests/components/velbus/test_switch.py +++ b/tests/components/velbus/test_switch.py @@ -43,7 +43,7 @@ async def test_switch_on_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) mock_relay.turn_off.assert_called_once_with() @@ -51,7 +51,7 @@ async def test_switch_on_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.relayname"}, + {ATTR_ENTITY_ID: "switch.living_room_relayname"}, blocking=True, ) mock_relay.turn_on.assert_called_once_with() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 512b2a007ed..c88a21d2bba 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.velux import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +18,44 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.velux.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_velux_client() -> Generator[AsyncMock]: + """Mock a Velux client.""" + with ( + patch( + "homeassistant.components.velux.config_flow.PyVLX", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_user_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + +@pytest.fixture +def mock_discovered_config_entry() -> MockConfigEntry: + """Return the user config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="127.0.0.1", + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + CONF_MAC: "64:61:84:00:ab:cd", + }, + unique_id="VELUX_KLF_ABCD", + ) diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 5f7932d358a..22ad10e1188 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -2,86 +2,288 @@ from __future__ import annotations -from copy import deepcopy -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from pyvlx import PyVLXException from homeassistant.components.velux import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -DUMMY_DATA: dict[str, Any] = { - CONF_HOST: "127.0.0.1", - CONF_PASSWORD: "NotAStrongPassword", -} - -PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH = ( - "homeassistant.components.velux.config_flow.PyVLX.connect" +DHCP_DISCOVERY = DhcpServiceInfo( + ip="127.0.0.1", + hostname="VELUX_KLF_LAN_ABCD", + macaddress="64618400abcd", ) -PYVLX_CONFIG_FLOW_CLASS_PATH = "homeassistant.components.velux.config_flow.PyVLX" - -error_types_to_test: list[tuple[Exception, str]] = [ - (PyVLXException("DUMMY"), "cannot_connect"), - (Exception("DUMMY"), "unknown"), -] - -pytest.mark.usefixtures("mock_setup_entry") -async def test_user_success(hass: HomeAssistant) -> None: +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, +) -> None: """Test starting a flow by user with valid values.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True) as client_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - client_mock.return_value.disconnect.assert_called_once() - client_mock.return_value.connect.assert_called_once() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DUMMY_DATA[CONF_HOST] - assert result["data"] == DUMMY_DATA + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "127.0.0.1" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + } + assert not result["result"].unique_id + + mock_velux_client.disconnect.assert_called_once() + mock_velux_client.connect.assert_called_once() -@pytest.mark.parametrize(("error", "error_name"), error_types_to_test) +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) async def test_user_errors( - hass: HomeAssistant, error: Exception, error_name: str + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, ) -> None: """Test starting a flow by user but with exceptions.""" - with patch( - PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH, side_effect=error - ) as connect_mock: - result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) - ) - connect_mock.assert_called_once() + mock_velux_client.connect.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": error_name} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.assert_called_once() + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_flow_duplicate_entry(hass: HomeAssistant) -> None: +async def test_user_flow_duplicate_entry( + hass: HomeAssistant, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: """Test initialized flow with a duplicate entry.""" - with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True): - conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title=DUMMY_DATA[CONF_HOST], data=DUMMY_DATA - ) + mock_user_config_entry.add_to_hass(hass) - conf_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=DUMMY_DATA, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + assert result["result"].unique_id == "VELUX_KLF_ABCD" + + mock_velux_client.disconnect.assert_called() + mock_velux_client.connect.assert_called() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_velux_client.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result["errors"] == {"base": error} + + mock_velux_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "NotAStrongPassword"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "VELUX_KLF_ABCD" + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "64:61:84:00:ab:cd", + CONF_NAME: "VELUX_KLF_ABCD", + CONF_PASSWORD: "NotAStrongPassword", + } + + +async def test_dhcp_discovery_already_configured( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_discovered_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when already configured.""" + mock_discovered_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discover_unique_id( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery when already configured.""" + mock_user_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_user_config_entry.entry_id) + + assert mock_user_config_entry.state is ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id == "VELUX_KLF_ABCD" + + +async def test_dhcp_discovery_not_loaded( + hass: HomeAssistant, + mock_velux_client: AsyncMock, + mock_user_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test dhcp discovery when entry with same host not loaded.""" + mock_user_config_entry.add_to_hass(hass) + + assert mock_user_config_entry.state is not ConfigEntryState.LOADED + assert mock_user_config_entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_user_config_entry.unique_id is None diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e6dd11669d1..eb7e3eb1811 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -8,7 +8,6 @@ import pytest from verisure import Error as VerisureError, LoginError as VerisureLoginError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, @@ -18,6 +17,7 @@ from homeassistant.components.verisure.const import ( from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -333,7 +333,7 @@ async def test_dhcp(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" result = await hass.config_entries.flow.async_init( DOMAIN, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.4", macaddress="0123456789ab", hostname="mock_hostname" ), context={"source": config_entries.SOURCE_DHCP}, diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 954affb4c1a..39a92778727 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -10,13 +10,18 @@ from homeassistant.util.json import JsonObjectType from tests.common import load_fixture, load_json_object_fixture +ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" +ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" +ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" +ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] ] DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Humidifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-200s.json") ], "Humidifier 600S": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") @@ -47,6 +52,9 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") ], "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], + "SmartTowerFan": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "SmartTowerFan-detail.json") + ], } diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 5500ef1a55f..df6ebbdf6e7 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -7,9 +7,10 @@ from unittest.mock import Mock, patch import pytest from pyvesync import VeSync from pyvesync.vesyncbulb import VeSyncBulb -from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncfan import VeSyncAirBypass, VeSyncHumid200300S from pyvesync.vesyncoutlet import VeSyncOutlet from pyvesync.vesyncswitch import VeSyncSwitch +import requests_mock from homeassistant.components.vesync import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -17,6 +18,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType +from .common import mock_multiple_device_responses + from tests.common import MockConfigEntry @@ -100,3 +103,118 @@ def dimmable_switch_fixture(): def outlet_fixture(): """Create a mock VeSync outlet fixture.""" return Mock(VeSyncOutlet) + + +@pytest.fixture(name="humidifier") +def humidifier_fixture(): + """Create a mock VeSync Classic200S humidifier fixture.""" + return Mock( + VeSyncHumid200300S, + cid="200s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={ + "humidity": 35, + "mode": "manual", + }, + device_type="Classic200S", + device_name="Humidifier 200s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) + + +@pytest.fixture(name="humidifier_300s") +def humidifier_300s_fixture(): + """Create a mock VeSync Classic300S humidifier fixture.""" + return Mock( + VeSyncHumid200300S, + cid="300s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={"humidity": 35, "mode": "manual", "night_light_brightness": 50}, + device_type="Classic300S", + device_name="Humidifier 300s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + night_light=True, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) + + +@pytest.fixture(name="humidifier_config_entry") +async def humidifier_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `Humidifier 200s`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "Humidifier 200s" + mock_multiple_device_responses(requests_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +@pytest.fixture +async def install_humidifier_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager, + request: pytest.FixtureRequest, +) -> None: + """Create a mock VeSync config entry with the specified humidifier device.""" + + # Install the defined humidifier + manager._dev_list["fans"].append(request.getfixturevalue(request.param)) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.fixture(name="switch_old_id_config_entry") +async def switch_old_id_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `switch` with the old unique ID approach.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + wall_switch = "Wall Switch" + humidifer = "Humidifier 200s" + + mock_multiple_device_responses(requests_mock, [wall_switch, humidifer]) + + return entry diff --git a/tests/components/vesync/fixtures/SmartTowerFan-detail.json b/tests/components/vesync/fixtures/SmartTowerFan-detail.json new file mode 100644 index 00000000000..061dcb5b0d0 --- /dev/null +++ b/tests/components/vesync/fixtures/SmartTowerFan-detail.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "manualSpeedLevel": 1, + "fanSpeedLevel": 0, + "screenState": 0, + "screenSwitch": 0, + "oscillationSwitch": 1, + "oscillationState": 1, + "muteSwitch": 1, + "muteState": 1, + "timerRemain": 0, + "temperature": 717, + "humidity": 40, + "thermalComfort": 65, + "errorCode": 0, + "sleepPreference": { + "sleepPreferenceType": "default", + "oscillationSwitch": 0, + "initFanSpeedLevel": 0, + "fallAsleepRemain": 0, + "autoChangeFanLevelSwitch": 0 + }, + "scheduleCount": 0, + "displayingType": 0 + } + } +} diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-200s.json new file mode 100644 index 00000000000..a0a98bde110 --- /dev/null +++ b/tests/components/vesync/fixtures/humidifier-200s.json @@ -0,0 +1,27 @@ +{ + "code": 0, + "result": { + "result": { + "humidity": 35, + "mist_level": 6, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index eac2bf9f5fa..3109fd3ea40 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -6,7 +6,7 @@ "cid": "200s-humidifier", "deviceType": "Classic200S", "deviceName": "Humidifier 200s", - "subDeviceNo": null, + "subDeviceNo": 4321, "deviceStatus": "on", "connectionStatus": "online", "uuid": "00000000-1111-2222-3333-444444444444", @@ -108,6 +108,15 @@ "deviceStatus": "on", "connectionStatus": "online", "configModule": "configModule" + }, + { + "cid": "smarttowerfan", + "deviceType": "LTF-F422S-KEU", + "deviceName": "SmartTowerFan", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 54ed8acf2d7..407e18d65b6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -128,7 +128,7 @@ 'sleep', 'manual', ]), - 'mode': None, + 'mode': 'humidity', 'night_light': True, 'pid': None, 'speed': None, @@ -160,6 +160,31 @@ # --- # name: test_async_get_device_diagnostics__single_fan dict({ + '_config_dict': dict({ + 'features': list([ + 'air_quality', + ]), + 'levels': list([ + 1, + 2, + ]), + 'models': list([ + 'LV-PUR131S', + 'LV-RH131S', + 'LV-RH131S-WM', + ]), + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), + 'module': 'VeSyncAir131', + }), + '_features': list([ + 'air_quality', + ]), + 'air_quality_feature': True, 'cid': 'abcdefghabcdefghabcdefghabcdefgh', 'config': dict({ }), @@ -180,6 +205,7 @@ 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', + 'enabled': True, 'extension': None, 'home_assistant': dict({ 'disabled': False, @@ -271,6 +297,12 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), 'pid': None, 'speed': None, 'sub_device_no': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 60af4ae3d5b..0b56a08eeff 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -46,6 +47,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -96,6 +98,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -137,6 +140,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -235,6 +240,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -292,6 +298,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -334,6 +341,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -391,6 +399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -429,6 +438,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -464,6 +474,37 @@ # --- # name: test_fan_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier4321', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 200s][entities] @@ -472,6 +513,37 @@ # --- # name: test_fan_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_fan_state[Humidifier 600S][entities] @@ -483,6 +555,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -516,11 +589,117 @@ list([ ]) # --- +# name: test_fan_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.smarttowerfan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'smarttowerfan', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][fan.smarttowerfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'SmartTowerFan', + 'mode': 'normal', + 'night_light': 'off', + 'percentage': None, + 'percentage_step': 7.6923076923076925, + 'preset_mode': None, + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + 'screen_status': False, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.smarttowerfan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_fan_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -559,6 +738,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 2e7fe9ac1bb..bed711b1040 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -42,6 +43,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -80,6 +82,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -118,6 +121,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -156,6 +160,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -197,6 +202,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +252,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -287,6 +294,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +343,37 @@ # --- # name: test_light_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier4321', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 200s][entities] @@ -343,6 +382,37 @@ # --- # name: test_light_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_light_state[Humidifier 600S][entities] @@ -354,6 +424,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -387,11 +458,51 @@ list([ ]) # --- +# name: test_light_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_light_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -437,6 +548,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -497,6 +609,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 11d931e023a..c701fa8a324 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -43,6 +44,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +76,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -134,6 +137,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -173,6 +177,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -220,6 +225,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -259,6 +265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +297,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -323,6 +331,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -399,6 +408,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -438,6 +448,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +480,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -502,6 +514,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -578,6 +591,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -616,6 +630,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -651,25 +666,188 @@ # --- # name: test_sensor_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier4321', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 200s][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_200s_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '200s-humidifier4321-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 200s][sensor.humidifier_200s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 200s Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_200s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_sensor_state[Humidifier 600S][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_600s_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '600s-humidifier-humidity', + 'unit_of_measurement': '%', + }), ]) # --- +# name: test_sensor_state[Humidifier 600S][sensor.humidifier_600s_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 600S Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humidifier_600s_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- # name: test_sensor_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -709,6 +887,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -742,6 +921,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -775,6 +955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -808,6 +989,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -841,6 +1023,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -874,6 +1057,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -997,11 +1181,51 @@ 'state': '0', }) # --- +# name: test_sensor_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_sensor_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1040,6 +1264,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 4b271ee55d9..1faed941338 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -42,6 +43,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -80,6 +82,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -118,6 +121,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -156,6 +160,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -194,6 +199,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -229,6 +235,37 @@ # --- # name: test_switch_state[Humidifier 200s][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '200s-humidifier4321', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'Classic200S', + 'model_id': None, + 'name': 'Humidifier 200s', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 200s][entities] @@ -237,6 +274,37 @@ # --- # name: test_switch_state[Humidifier 600S][devices] list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + '600s-humidifier', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LUH-A602S-WUS', + 'model_id': None, + 'name': 'Humidifier 600S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), ]) # --- # name: test_switch_state[Humidifier 600S][entities] @@ -248,6 +316,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -285,6 +354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,14 +370,14 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'outlet', + 'unique_id': 'outlet-device_status', 'unit_of_measurement': None, }), ]) @@ -315,6 +385,7 @@ # name: test_switch_state[Outlet][switch.outlet] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', 'friendly_name': 'Outlet', }), 'context': , @@ -325,11 +396,51 @@ 'state': 'on', }) # --- +# name: test_switch_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -368,6 +479,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -405,6 +517,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,14 +533,14 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'switch', + 'unique_id': 'switch-device_status', 'unit_of_measurement': None, }), ]) @@ -435,6 +548,7 @@ # name: test_switch_state[Wall Switch][switch.wall_switch] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'switch', 'friendly_name': 'Wall Switch', }), 'context': , diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index 22a93e1ba56..38f28e73aed 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -48,3 +48,59 @@ async def test_config_flow_user_input(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_USERNAME] == "user" assert result["data"][CONF_PASSWORD] == "pass" + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a successful reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + } + + +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test an authorization error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + + with patch("pyvesync.vesync.VeSync.login", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index b948053c3a0..25aa5337281 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -101,6 +101,9 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.2.state.last_changed": (str,), "home_assistant.entities.2.state.last_reported": (str,), "home_assistant.entities.2.state.last_updated": (str,), + "home_assistant.entities.3.state.last_changed": (str,), + "home_assistant.entities.3.state.last_reported": (str,), + "home_assistant.entities.3.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py new file mode 100644 index 00000000000..d5057c44951 --- /dev/null +++ b/tests/components/vesync/test_humidifier.py @@ -0,0 +1,329 @@ +"""Tests for the humidifier platform.""" + +from contextlib import nullcontext +import logging +from unittest.mock import patch + +import pytest + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_SLEEP, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.components.vesync.const import ( + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from .common import ( + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, +) + +from tests.common import MockConfigEntry + +NoException = nullcontext() + + +async def test_humidifier_state( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the resulting setup state is as expected for the platform.""" + + expected_entities = [ + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, + ] + + assert humidifier_config_entry.state is ConfigEntryState.LOADED + + for entity_id in expected_entities: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + state = hass.states.get(ENTITY_HUMIDIFIER) + + # ATTR_HUMIDITY represents the target_humidity which comes from configuration.auto_target_humidity node + assert state.attributes.get(ATTR_HUMIDITY) == 40 + + +async def test_set_target_humidity_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_humidify method.""" + + # Setting value out of range results in ServiceValidationError and + # VeSyncHumid200300S.set_humidity does not get called. + with ( + patch("pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity") as method_mock, + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 20}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_target_humidity( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of return value from VeSyncHumid200300S.set_humidity.""" + + # If VeSyncHumid200300S.set_humidity fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 54}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_on( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_on method.""" + + # turn_on returns False indicating failure in which case humidifier.turn_on + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(False, pytest.raises(HomeAssistantError)), (True, NoException)], +) +async def test_turn_off( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test turn_off method.""" + + # turn_off returns False indicating failure in which case humidifier.turn_off + # raises HomeAssistantError. + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off", return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.humidifier.VeSyncHumidifierHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +async def test_set_mode_invalid( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, +) -> None: + """Test handling of invalid value in set_mode method.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" + ) as method_mock: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "something_invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_mode( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_mode method.""" + + # If VeSyncHumid200300S.set_humidity_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode", + return_value=api_response, + ) as method_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: MODE_AUTO}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_base_unique_id( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unique_id is based on subDeviceNo.""" + # vesync-device.json defines subDeviceNo for 200s-humidifier as 4321. + entity = entity_registry.async_get(ENTITY_HUMIDIFIER) + assert entity.unique_id.endswith("4321") + + +async def test_invalid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported mist mode.""" + + humidifier.mist_modes = ["invalid_mode"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'invalid_mode'" in caplog.text + + +async def test_valid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test supported mist mode.""" + + humidifier.mist_modes = ["auto", "manual"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'auto'" not in caplog.text + assert "Unknown mode 'manual'" not in caplog.text + + +async def test_set_mode_sleep_turns_display_off( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, +) -> None: + """Test update of display for sleep mode.""" + + # First define valid mist modes + humidifier.mist_modes = [ + VS_HUMIDIFIER_MODE_AUTO, + VS_HUMIDIFIER_MODE_MANUAL, + VS_HUMIDIFIER_MODE_SLEEP, + ] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with ( + patch.object(humidifier, "set_humidity_mode", return_value=True), + patch.object(humidifier, "set_display") as display_mock, + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: MODE_SLEEP}, + blocking=True, + ) + display_mock.assert_called_once_with(False) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index a089a270c94..31df2418b3d 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -2,44 +2,33 @@ from unittest.mock import Mock, patch -import pytest from pyvesync import VeSync -from homeassistant.components.vesync import async_setup_entry -from homeassistant.components.vesync.const import ( - DOMAIN, - VS_FANS, - VS_LIGHTS, - VS_MANAGER, - VS_SENSORS, - VS_SWITCHES, -) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry +from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry async def test_async_setup_entry__not_login( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, - caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not create config entry when not logged in.""" manager.login = Mock(return_value=False) - with ( - patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch("homeassistant.components.vesync.async_process_devices") as process_mock, - ): - assert not await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() - assert setups_mock.call_count == 0 - assert process_mock.call_count == 0 + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert manager.login.call_count == 1 - assert DOMAIN not in hass.data - assert "Unable to login to the VeSync server" in caplog.text + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_async_setup_entry__no_devices( @@ -52,20 +41,26 @@ async def test_async_setup_entry__no_devices( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [] + assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert not hass.data[DOMAIN][VS_FANS] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert not hass.data[DOMAIN][VS_SENSORS] + assert not hass.data[DOMAIN][VS_DEVICES] async def test_async_setup_entry__loads_fans( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan ) -> None: - """Test setup connects to vesync and loads fan platform.""" + """Test setup connects to vesync and loads fan.""" fans = [fan] manager.fans = fans manager._dev_list = { @@ -78,10 +73,103 @@ async def test_async_setup_entry__loads_fans( await hass.async_block_till_done() assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry - assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_SWITCHES] - assert hass.data[DOMAIN][VS_FANS] == [fan] - assert not hass.data[DOMAIN][VS_LIGHTS] - assert hass.data[DOMAIN][VS_SENSORS] == [fan] + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + +async def test_async_new_device_discovery( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan, humidifier +) -> None: + """Test new device discovery.""" + + assert await hass.config_entries.async_setup(config_entry.entry_id) + # Assert platforms loaded + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert not hass.data[DOMAIN][VS_DEVICES] + + # Mock discovery of new fan which would get added to VS_DEVICES. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[fan], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + # Mock discovery of new humidifier which would invoke discovery in all platforms. + # The mocked humidifier needs to have all properties populated for correct processing. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] + + +async def test_migrate_config_entry( + hass: HomeAssistant, + switch_old_id_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration of config entry. Only migrates switches to a new unique_id.""" + switch: er.RegistryEntry = entity_registry.async_get_or_create( + domain="switch", + platform="vesync", + unique_id="switch", + config_entry=switch_old_id_config_entry, + suggested_object_id="switch", + ) + + humidifer: er.RegistryEntry = entity_registry.async_get_or_create( + domain="humidifer", + platform="vesync", + unique_id="humidifer", + config_entry=switch_old_id_config_entry, + suggested_object_id="humidifer", + ) + + assert switch.unique_id == "switch" + assert switch_old_id_config_entry.minor_version == 1 + assert humidifer.unique_id == "humidifer" + + await hass.config_entries.async_setup(switch_old_id_config_entry.entry_id) + await hass.async_block_till_done() + + assert switch_old_id_config_entry.minor_version == 2 + + migrated_switch = entity_registry.async_get(switch.entity_id) + assert migrated_switch is not None + assert migrated_switch.entity_id.startswith("switch") + assert migrated_switch.unique_id == "switch-device_status" + # Confirm humidifer was not impacted + migrated_humidifer = entity_registry.async_get(humidifer.entity_id) + assert migrated_humidifer is not None + assert migrated_humidifer.unique_id == "humidifer" + + # Assert that only one entity exists in the switch domain + switch_entities = [ + e for e in entity_registry.entities.values() if e.domain == "switch" + ] + assert len(switch_entities) == 1 + + humidifer_entities = [ + e for e in entity_registry.entities.values() if e.domain == "humidifer" + ] + assert len(humidifer_entities) == 1 diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py new file mode 100644 index 00000000000..a9230b76db0 --- /dev/null +++ b/tests/components/vesync/test_number.py @@ -0,0 +1,66 @@ +"""Tests for the number platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .common import ENTITY_HUMIDIFIER_MIST_LEVEL + +from tests.common import MockConfigEntry + + +async def test_set_mist_level_bad_range( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level invalid value.""" + with ( + pytest.raises(ServiceValidationError), + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock, + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "10"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +async def test_set_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level usage.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "3"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of mist_level number entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_MIST_LEVEL).state == "6" diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py new file mode 100644 index 00000000000..30c83c89e0e --- /dev/null +++ b/tests/components/vesync/test_select.py @@ -0,0 +1,54 @@ +"""Tests for the select platform.""" + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_set_nightlight_level( + hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device +) -> None: + """Test set of night light level.""" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, + ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + }, + blocking=True, + ) + + # Assert that setter API was invoked with the expected translated value + humidifier_300s.set_night_light_brightness.assert_called_once_with( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + ) + # Assert that devices were refreshed + manager.update_all_devices.assert_called_once() + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: + """Test the state of night light level select entity.""" + + # The mocked device has night_light_brightness=50 which is "dim" + assert ( + hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state + == NIGHT_LIGHT_LEVEL_DIM + ) diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index bd3a8eb8591..04d759de584 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry @@ -49,3 +49,11 @@ async def test_sensor_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_humidity( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of humidity sensor entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_HUMIDITY).state == "35" diff --git a/tests/components/vicare/fixtures/RoomSensor1.json b/tests/components/vicare/fixtures/RoomSensor1.json index b970e54a48c..6c2f38db8d1 100644 --- a/tests/components/vicare/fixtures/RoomSensor1.json +++ b/tests/components/vicare/fixtures/RoomSensor1.json @@ -51,6 +51,24 @@ "timestamp": "2024-03-01T04:40:59.911Z", "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/zigbee-d87a3bfffe5d844a/features/device.name" }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "zigbee-d87a3bfffe5d844a", + "feature": "device.power.battery", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "level": { + "type": "number", + "unit": "percent", + "value": 89 + } + }, + "timestamp": "2025-02-03T02:30:52.279Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/zigbee-d87a3bfffe5d844a/features/device.power.battery" + }, { "apiVersion": 1, "commands": {}, diff --git a/tests/components/vicare/fixtures/VitoPure.json b/tests/components/vicare/fixtures/VitoPure.json new file mode 100644 index 00000000000..1e1cdef97ec --- /dev/null +++ b/tests/components/vicare/fixtures/VitoPure.json @@ -0,0 +1,645 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.filterChange", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.filterChange" + }, + { + "apiVersion": 1, + "commands": { + "setLevel": { + "isExecutable": true, + "name": "setLevel", + "params": { + "level": { + "constraints": { + "enum": ["levelOne", "levelTwo", "levelThree", "levelFour"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent/commands/setLevel" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.permanent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.sensorDriven", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.sensorDriven" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelTwo" + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.forcedLevelFour", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.silent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 0, + "busType": "OwnBus", + "productFamily": "B_00059_VP300", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitopure350" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["permanent", "ventilation", "sensorDriven"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "sensorDriven" + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "unknown" + }, + "level": { + "type": "string", + "value": "unknown" + }, + "reason": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 4, + "modes": ["levelOne", "levelTwo", "levelThree", "levelFour"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + } + ] +} diff --git a/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json new file mode 100644 index 00000000000..a733d33a12a --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json @@ -0,0 +1,3019 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T20:58:18.395Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -15, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.4 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 8, + "modes": ["reduced", "normal", "fixed"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["dhw", "dhwAndHeating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "dhwAndHeating" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normal" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-02-11T20:49:01.456Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 33.2 + } + }, + "timestamp": "2025-02-11T19:48:05.380Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 44 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 4332.4 + }, + "starts": { + "type": "number", + "unit": "", + "value": 21314 + } + }, + "timestamp": "2025-02-11T20:34:55.482Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1.statistics", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-02-11T20:45:56.068Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.controller.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.controller.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.charging", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.charging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["5/25-cycles", "5/10-cycles", "on"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ] + } + } + }, + "timestamp": "2025-02-11T17:50:12.565Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.primary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.primary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["top", "normal", "temp-2"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.bottom", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": false, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 50 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.temp2", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 60 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 6.9 + } + }, + "timestamp": "2025-02-11T20:58:31.054Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 5.2 + } + }, + "timestamp": "2025-02-11T20:48:38.307Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.9 + } + }, + "timestamp": "2025-02-11T20:46:37.502Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 1.9 + } + }, + "timestamp": "2025-02-11T21:00:13.154Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.5 + } + }, + "timestamp": "2025-02-11T20:48:00.474Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.cumulativeProduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.cumulativeProduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.production", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.pumps.circuit", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.pumps.circuit" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["standby", "standard", "ventilation"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "ventilation" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.standard", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.standard" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelThree" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "ventilation" + }, + "level": { + "type": "string", + "value": "levelThree" + }, + "reason": { + "type": "string", + "value": "schedule" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "levelOne", + "maxEntries": 8, + "modes": ["levelTwo", "levelThree", "levelFour"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-01-12T22:36:28.706Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.2.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.name" + } + ] +} diff --git a/tests/components/vicare/fixtures/Vitocal250A.json b/tests/components/vicare/fixtures/Vitocal250A.json new file mode 100644 index 00000000000..1da43531a89 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal250A.json @@ -0,0 +1,4447 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productMatrix", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "array", + "value": [ + { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + }, + { + "busAddress": 71, + "busType": "CanExternal", + "productFamily": "B_00012_VCH200", + "viessmannIdentificationNumber": "################" + } + ] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productMatrix" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "deviceSerialVitocal250A" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 44.6 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["1"] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.mode" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "Heizkreis" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -13, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0.2, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 1.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "reduced", + "maxEntries": 4, + "modes": ["normal", "comfort"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "comfort", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "Heizkreis" + } + }, + "timestamp": "2024-09-20T08:56:49.795Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["heating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "heating" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "comfortHeating" + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "eco" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 24 + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T03:59:26.407Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": { + "removeZigbeeController": { + "isExecutable": false, + "name": "removeZigbeeController", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController/commands/removeZigbeeController" + }, + "setZigbeeController": { + "isExecutable": true, + "name": "setZigbeeController", + "params": { + "deviceId": { + "constraints": { + "enum": [] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController/commands/setZigbeeController" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.remoteController", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.1 + } + }, + "timestamp": "2024-10-01T16:05:52.313Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 39 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:26:48.295Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 55 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.mode", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:09:57.180Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.circuits.N.operating.programs.reducedEnergySaving and heating.circuits.0.operating.programs.eco", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.remoteController", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.remoteController" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "phase": { + "type": "string", + "value": "ready" + } + }, + "timestamp": "2024-10-01T16:12:14.713Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.heat.production.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 13.317 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.heat.production.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "kilowatt", + "value": 3.107 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 5.4, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.700Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 93.9, 41.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.768Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [13, 21.799999999999997, 20.5, 27.4, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T11:46:35.700Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [143, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T11:45:28.937Z" + } + }, + "timestamp": "2024-10-01T12:18:26.686Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 31.2, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 36.7, 2.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [47.599999999999994, 0, 0, 5.5, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [55.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T16:25:33.871Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 36.6, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 130.60000000000002, 43.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [60.599999999999994, 21.799999999999997, 20.5, 32.9, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [198.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 71 + }, + "starts": { + "type": "number", + "unit": "", + "value": 121 + } + }, + "timestamp": "2024-10-01T16:12:54.682Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/activate" + }, + "disable": { + "isExecutable": false, + "name": "disable", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/disable" + }, + "enable": { + "isExecutable": true, + "name": "enable", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/enable" + } + }, + "deviceId": "0", + "feature": "heating.dhw.hygiene", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.hygiene.trigger", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene.trigger" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["efficientWithMinComfort", "efficient", "off"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "efficientWithMinComfort" + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.balanced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.balanced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficient", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficient" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficientWithMinComfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficientWithMinComfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.off", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.off" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.secondary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.secondary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "08:00" + } + ] + } + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 58.8 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 58.8 + } + }, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.middle", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:40.965Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hygiene" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 2.5, + "min": 0, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 0 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "default": { + "type": "number", + "unit": "celsius", + "value": 50 + }, + "max": { + "type": "number", + "unit": "celsius", + "value": 10 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 10 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 0, + "efficientUpperBorder": 55, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 47 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.heat.production.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.heat.production.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:26.139Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0, 0, 0, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [0, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 0 + }, + "starts": { + "type": "number", + "unit": "", + "value": 0 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.status", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "level1": { + "type": "boolean", + "value": false + }, + "level2": { + "type": "boolean", + "value": false + }, + "level3": { + "type": "boolean", + "value": false + }, + "overall": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.status" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holidayAtHome", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T00:31:26.264Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "kilowatt", + "value": 3.107 + } + }, + "timestamp": "2024-10-01T16:28:29.219Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 5.4, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [7.6, 93.9, 41.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [13, 21.799999999999997, 20.5, 27.4, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [143, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T12:18:26.686Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 31.2, 0, 0, 0, 0, 0, 0] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [16.4, 36.7, 2.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [47.599999999999994, 0, 0, 5.5, 0] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [55.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.552Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 7.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 7.6 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 143 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 93.9 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 28.3 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T11:46:54.639Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 16.4 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 16.4 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 55.2 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 36.7 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 47.6 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 36.6, 3, 2.6, 4.3, 1.2, 4.2, 2.7] + }, + "dayValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [24, 130.60000000000002, 43.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + "monthValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [60.599999999999994, 21.799999999999997, 20.5, 32.9, 16.2] + }, + "weekValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [198.2, 0] + }, + "yearValueReadAt": { + "type": "string", + "value": "2024-10-01T00:31:23.543Z" + } + }, + "timestamp": "2024-10-01T16:27:05.568Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 12.8 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.dhw", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 4.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.heating", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.2 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.total", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.scop.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 45.1 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.pressure.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 2.1 + } + }, + "timestamp": "2024-10-01T15:06:07.125Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.pressure.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.8 + } + }, + "timestamp": "2024-10-01T16:28:20.497Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-10-01T16:28:33.694Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 14.3 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 35.3 + } + }, + "timestamp": "2024-10-01T16:28:04.882Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.volumetricFlow.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "liter/hour", + "value": 1015 + } + }, + "timestamp": "2024-10-01T16:28:36.488Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.volumetricFlow.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 4.1 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.2 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2024-10-01T00:31:21.381Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.spf.total" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr index f3e4d4e1c84..93e407ea505 100644 --- a/tests/components/vicare/snapshots/test_binary_sensor.ambr +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -373,6 +381,54 @@ 'state': 'unavailable', }) # --- +# name: test_all_entities[binary_sensor.model0_one_time_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.model0_one_time_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'One-time charge', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'one_time_charge', + 'unique_id': 'gateway0_deviceSerialVitodens300W-one_time_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.model0_one_time_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 One-time charge', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_one_time_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_binary_sensors[burner] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/vicare/snapshots/test_button.ambr b/tests/components/vicare/snapshots/test_button.ambr index 9fadc6a983f..17dfc29e96e 100644 --- a/tests/components/vicare/snapshots/test_button.ambr +++ b/tests/components/vicare/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_climate.ambr b/tests/components/vicare/snapshots/test_climate.ambr index aea0ea879c2..e1709acea42 100644 --- a/tests/components/vicare/snapshots/test_climate.ambr +++ b/tests/components/vicare/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +102,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index ae9b05389c7..0b1dcef5a29 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4731,6 +4731,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'ViCare', 'version': 1, diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 3ecc4277fd9..2c9e815f7bf 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,130 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[fan.model1_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model1_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway1_deviceId1-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model1_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model1 Ventilation', + 'icon': 'mdi:fan-off', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model1_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[fan.model2_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model2_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway2_################-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model2_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model2 Ventilation', + 'icon': 'mdi:fan', + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model2_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/vicare/snapshots/test_number.ambr b/tests/components/vicare/snapshots/test_number.ambr index 5a030fc0213..b26d2d33590 100644 --- a/tests/components/vicare/snapshots/test_number.ambr +++ b/tests/components/vicare/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,64 @@ 'state': 'unavailable', }) # --- +# name: test_all_entities[number.model0_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.model0_dhw_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.model0_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW temperature', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.model0_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_all_entities[number.model0_heating_curve_shift-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -125,6 +185,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +243,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +301,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +357,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +413,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -406,6 +471,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +529,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -520,6 +587,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -565,60 +633,3 @@ 'state': 'unavailable', }) # --- -# name: test_all_entities[number.model0_dhw_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.model0_dhw_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'DHW temperature', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dhw_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[number.model0_dhw_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'model0 DHW temperature', - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.model0_dhw_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 793f3e87611..a0d4bf374c8 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.model0_boiler_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -34,7 +35,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_boiler_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -50,7 +51,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_burner_hours-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_hours-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,7 +87,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_burner_hours-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_hours-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner hours', @@ -100,7 +102,7 @@ 'state': '18726.3', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_modulation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -135,7 +138,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.model0_burner_modulation-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_modulation-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner modulation', @@ -150,7 +153,7 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.model0_burner_starts-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_starts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -185,7 +189,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_burner_starts-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_burner_starts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 Burner starts', @@ -199,7 +203,7 @@ 'state': '14315', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_month-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,7 +239,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_month-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_month-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this month', @@ -248,7 +253,7 @@ 'state': '805', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -257,6 +262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -283,7 +289,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this week', @@ -297,7 +303,7 @@ 'state': '84', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_year-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -306,6 +312,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,7 +339,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_this_year-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_this_year-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption this year', @@ -346,7 +353,7 @@ 'state': '8203', }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -355,6 +362,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -381,7 +389,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.model0_dhw_gas_consumption_today-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_gas_consumption_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model0 DHW gas consumption today', @@ -395,7 +403,7 @@ 'state': '22', }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_max_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -404,6 +412,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,7 +439,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_max_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_max_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -446,7 +455,7 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_min_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -455,6 +464,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,7 +491,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_dhw_min_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_dhw_min_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -497,407 +507,7 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.model0_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power consumption this month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '7.843', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_this_year', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Electricity consumption this year', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_consumption_this_year', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_year-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption this year', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_this_year', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '207.106', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_electricity_consumption_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Electricity consumption today', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'power_consumption_today', - 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'model0 Electricity consumption today', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_electricity_consumption_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.219', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption this month', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_month', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_month-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this month', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption this week', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_week', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_week-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this week', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption this year', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_this_year', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_this_year-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption this year', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '30946', - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_heating_gas_consumption_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Heating gas consumption today', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'gas_consumption_heating_today', - 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.model0_heating_gas_consumption_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Heating gas consumption today', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.model0_heating_gas_consumption_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[sensor.model0_outside_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.model0_outside_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Outside temperature', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'outside_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.model0_outside_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'model0 Outside temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.model0_outside_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.8', - }) -# --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_week-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -906,6 +516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -932,7 +543,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_electricity_consumption_this_week-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_week-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -948,7 +559,363 @@ 'state': '0.829', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '207.106', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.219', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power consumption this month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.843', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this month', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_week-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption this week', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_week-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this week', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_week', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption this year', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30946', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_gas_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating gas consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_consumption_heating_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_heating_gas_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating gas consumption today', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_gas_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -957,6 +924,59 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -983,7 +1003,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -999,7 +1019,7 @@ 'state': '63', }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-entry] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1008,6 +1028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1034,7 +1055,7 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.model0_supply_temperature_2-state] +# name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_supply_temperature_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -1050,6 +1071,1623 @@ 'state': '25.5', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_buffer_main_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Buffer main temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'buffer_main_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-buffer main temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_buffer_main_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Buffer main temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_buffer_main_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor hours', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_hours', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_hours-0', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '71', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor phase', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_phase', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_phase-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor phase', + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_starts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_compressor_starts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Compressor starts', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_starts', + 'unique_id': 'gateway0_deviceSerialVitocal250A-compressor_starts-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_compressor_starts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Compressor starts', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_compressor_starts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '121', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption last seven days', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_dhw_consumption_heating_lastsevendays', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_dhw_consumption_heating_lastsevendays', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption last seven days', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentmonth', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentmonth', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentyear', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentyear', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '143', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_dhw_summary_consumption_heating_currentday', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_dhw_summary_consumption_heating_currentday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 DHW electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_max_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_max_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW max temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hotwater_max_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_max_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_max_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW max temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_max_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_min_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_min_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW min temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hotwater_min_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-hotwater_min_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_min_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW min temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_min_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_storage_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_dhw_storage_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW storage temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_storage_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-dhw_storage_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_dhw_storage_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW storage temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_dhw_storage_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '58.8', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitocal250A-power consumption today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption last seven days', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_lastsevendays', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_lastsevendays', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption last seven days', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.6', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption this month', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentmonth', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentmonth', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.4', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption this year', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentyear', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentyear', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.2', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_heating_electricity_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating electricity consumption today', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_summary_consumption_heating_currentday', + 'unique_id': 'gateway0_deviceSerialVitocal250A-energy_summary_consumption_heating_currentday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_electricity_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'model0 Heating electricity consumption today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_electricity_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.4', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_heating_rod_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating rod hours', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_rod_hours', + 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating rod hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_rod_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_starts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_heating_rod_starts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating rod starts', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_rod_starts', + 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_starts', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_starts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating rod starts', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_rod_starts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_primary_circuit_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_primary_circuit_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Primary circuit supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'primary_circuit_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-primary_circuit_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_primary_circuit_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Primary circuit supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_primary_circuit_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.8', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_return_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_return_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Return temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'return_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-return_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_return_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Return temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_return_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.3', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seasonal performance factor', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_total', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_total', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.9', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seasonal performance factor - domestic hot water', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_dhw', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_dhw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - domestic hot water', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seasonal performance factor - heating', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_heating', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - heating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.2', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_secondary_circuit_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Secondary circuit supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'secondary_circuit_supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-secondary_circuit_supply_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Secondary circuit supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_secondary_circuit_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_supply_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply pressure', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_pressure', + 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_pressure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'model0 Supply pressure', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_supply_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_supply_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_temperature', + 'unique_id': 'gateway0_deviceSerialVitocal250A-supply_temperature-1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_supply_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 Supply temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_supply_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_volumetric_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_volumetric_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volumetric flow', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volumetric_flow', + 'unique_id': 'gateway0_deviceSerialVitocal250A-volumetric_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_volumetric_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Volumetric flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_volumetric_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.015', + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model0_ventilation_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ventilation level', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_level', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation level', + 'options': list([ + 'standby', + 'levelone', + 'leveltwo', + 'levelthree', + 'levelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'levelone', + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ventilation reason', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilation_reason', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:ventilation-vicare/ViAir300F.json][sensor.model0_ventilation_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'model0 Ventilation reason', + 'options': list([ + 'standby', + 'permanent', + 'schedule', + 'sensordriven', + 'silent', + 'forcedlevelfour', + ]), + }), + 'context': , + 'entity_id': 'sensor.model0_ventilation_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'permanent', + }) +# --- +# name: test_room_sensors[sensor.model0_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'gateway0_zigbee_d87a3bfffe5d844a-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_room_sensors[sensor.model0_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'model0 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.model0_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89', + }) +# --- # name: test_room_sensors[sensor.model0_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1059,6 +2697,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +2749,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1161,6 +2801,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1212,6 +2853,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_water_heater.ambr b/tests/components/vicare/snapshots/test_water_heater.ambr index bca04b1bbfa..7b7ab91e086 100644 --- a/tests/components/vicare/snapshots/test_water_heater.ambr +++ b/tests/components/vicare/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'min_temp': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index f48a8988cf0..9299f6567b1 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -23,7 +23,9 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index d44fd1b9fed..ce3b3c27f06 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -9,12 +9,12 @@ from PyViCare.PyViCareUtils import ( ) from syrupy.assertion import SnapshotAssertion -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 homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import MOCK_MAC, MODULE @@ -28,7 +28,7 @@ VALID_CONFIG = { CONF_CLIENT_ID: "5678", } -DHCP_INFO = dhcp.DhcpServiceInfo( +DHCP_INFO = DhcpServiceInfo( ip="1.1.1.1", hostname="mock_hostname", macaddress=MOCK_MAC.lower().replace(":", ""), diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index aaf6a968ffd..8c42c92fb50 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -23,7 +23,11 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] + fixtures: list[Fixture] = [ + Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), + Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.FAN]), diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index afd3232478a..daad6bfa1c8 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -16,15 +16,25 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("fixture_type", "fixture_data"), + [ + ("type:boiler", "vicare/Vitodens300W.json"), + ("type:heatpump", "vicare/Vitocal250A.json"), + ("type:ventilation", "vicare/ViAir300F.json"), + ], +) async def test_all_entities( hass: HomeAssistant, + fixture_type: str, + fixture_data: str, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" fixtures: list[Fixture] = [ - Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + Fixture({fixture_type}, fixture_data), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), diff --git a/tests/components/vicare/test_utils.py b/tests/components/vicare/test_utils.py new file mode 100644 index 00000000000..13ca77f2792 --- /dev/null +++ b/tests/components/vicare/test_utils.py @@ -0,0 +1,23 @@ +"""Test ViCare utils.""" + +import pytest + +from homeassistant.components.vicare.utils import filter_state + + +@pytest.mark.parametrize( + ("state", "expected_result"), + [ + (None, None), + ("unknown", None), + ("nothing", None), + ("levelOne", "levelOne"), + ], +) +async def test_filter_state( + state: str | None, + expected_result: str | None, +) -> None: + """Test filter_state.""" + + assert filter_state(state) == expected_result diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 51151ae8f42..5fbf61a58da 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -2,7 +2,6 @@ from ipaddress import ip_address -from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, MediaPlayerDeviceClass, @@ -27,6 +26,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PIN, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import slugify NAME = "Vizio" @@ -173,7 +173,7 @@ VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local." ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST, ZEROCONF_PORT = HOST.split(":", maxsplit=2) -MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_SERVICE_INFO = ZeroconfServiceInfo( ip_address=ip_address(ZEROCONF_HOST), ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index e004255ec6d..9d776ba6a59 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID diff --git a/tests/components/vodafone_station/__init__.py b/tests/components/vodafone_station/__init__.py index 68f11a27b95..6119d94c06c 100644 --- a/tests/components/vodafone_station/__init__.py +++ b/tests/components/vodafone_station/__init__.py @@ -1 +1,13 @@ """Tests for the Vodafone Station integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/vodafone_station/conftest.py b/tests/components/vodafone_station/conftest.py index c36382e4c01..a065a1e8065 100644 --- a/tests/components/vodafone_station/conftest.py +++ b/tests/components/vodafone_station/conftest.py @@ -2,11 +2,31 @@ from datetime import UTC, datetime +from aiovodafone import VodafoneStationDevice import pytest -from .const import DEVICE_DATA_QUERY, SENSOR_DATA_QUERY +from homeassistant.components.vodafone_station import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from tests.common import AsyncMock, Generator, patch +from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC + +from tests.common import ( + AsyncMock, + Generator, + MockConfigEntry, + load_json_object_fixture, + patch, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.vodafone_station.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry @pytest.fixture @@ -17,12 +37,50 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]: "homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi", autospec=True, ) as mock_router, + patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi", + new=mock_router, + ), ): router = mock_router.return_value - router.get_devices_data.return_value = DEVICE_DATA_QUERY - router.get_sensor_data.return_value = SENSOR_DATA_QUERY + router.get_devices_data.return_value = { + DEVICE_1_MAC: VodafoneStationDevice( + connected=True, + connection_type="wifi", + ip_address="192.168.1.10", + name=DEVICE_1_HOST, + mac=DEVICE_1_MAC, + type="laptop", + wifi="2.4G", + ), + DEVICE_2_MAC: VodafoneStationDevice( + connected=False, + connection_type="lan", + ip_address="192.168.1.11", + name="LanDevice1", + mac=DEVICE_2_MAC, + type="desktop", + wifi="", + ), + } + router.get_sensor_data.return_value = load_json_object_fixture( + "get_sensor_data.json", DOMAIN + ) router.convert_uptime.return_value = datetime( 2024, 11, 19, 20, 19, 0, tzinfo=UTC ) router.base_url = "https://fake_host" yield router + + +@pytest.fixture +def mock_config_entry() -> Generator[MockConfigEntry]: + """Mock a Vodafone Station config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py index 60eb2aff6f4..cf6c274e5d5 100644 --- a/tests/components/vodafone_station/const.py +++ b/tests/components/vodafone_station/const.py @@ -1,118 +1,6 @@ """Common stuff for Vodafone Station tests.""" -from aiovodafone.api import VodafoneStationDevice - -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME - -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - } - ] - } -} - -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] - +DEVICE_1_HOST = "WifiDevice0" DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx" -DEVICE_1 = { - DEVICE_1_MAC: VodafoneStationDevice( - connected=True, - connection_type="wifi", - ip_address="192.168.1.10", - name="WifiDevice0", - mac=DEVICE_1_MAC, - type="laptop", - wifi="2.4G", - ), -} -DEVICE_DATA_QUERY = DEVICE_1 - -SERIAL = "m123456789" - -SENSOR_DATA_QUERY = { - "sys_serial_number": SERIAL, - "sys_firmware_version": "XF6_4.0.05.04", - "sys_bootloader_version": "0220", - "sys_hardware_version": "RHG3006 v1", - "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", - "sys_uptime": "12:16:41", - "sys_cpu_usage": "97%", - "sys_reboot_cause": "Web Reboot", - "sys_memory_usage": "51.94%", - "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", - "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", - "vf_internet_key_online_since": "", - "vf_internet_key_ip_addr": "0.0.0.0", - "vf_internet_key_system": "0.0.0.0", - "vf_internet_key_mode": "Auto", - "sys_voip_version": "v02.01.00_01.13a\n", - "sys_date_time": "20.10.2024 | 03:44 pm", - "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", - "sys_model_name": "RHG3006", - "inter_ip_address": "1.1.1.1", - "inter_gateway": "1.1.1.2", - "inter_primary_dns": "1.1.1.3", - "inter_secondary_dns": "1.1.1.4", - "inter_firewall": "601036", - "inter_wan_ip_address": "1.1.1.1", - "inter_ipv6_link_local_address": "", - "inter_ipv6_link_global_address": "", - "inter_ipv6_gateway": "", - "inter_ipv6_prefix_delegation": "", - "inter_ipv6_dns_address1": "", - "inter_ipv6_dns_address2": "", - "lan_ip_network": "192.168.0.1/24", - "lan_default_gateway": "192.168.0.1", - "lan_subnet_address_subnet1": "", - "lan_mac_address": "11:22:33:44:55:66", - "lan_dhcp_server": "601036", - "lan_dhcpv6_server": "601036", - "lan_router_advertisement": "601036", - "lan_ipv6_default_gateway": "fe80::1", - "lan_port1_switch_mode": "1301722", - "lan_port2_switch_mode": "1301722", - "lan_port3_switch_mode": "1301722", - "lan_port4_switch_mode": "1301722", - "lan_port1_switch_speed": "10", - "lan_port2_switch_speed": "100", - "lan_port3_switch_speed": "1000", - "lan_port4_switch_speed": "1000", - "lan_port1_switch_status": "1301724", - "lan_port2_switch_status": "1301724", - "lan_port3_switch_status": "1301724", - "lan_port4_switch_status": "1301724", - "wifi_status": "601036", - "wifi_name": "Wifi-Main-Network", - "wifi_mac_address": "AA:BB:CC:DD:EE:FF", - "wifi_security": "401027", - "wifi_channel": "8", - "wifi_bandwidth": "573", - "guest_wifi_status": "601037", - "guest_wifi_name": "Wifi-Guest", - "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", - "guest_wifi_security": "401027", - "guest_wifi_channel": "N/A", - "guest_wifi_ip": "192.168.2.1", - "guest_wifi_subnet_addr": "255.255.255.0", - "guest_wifi_dhcp_server": "192.168.2.1", - "wifi_status_5g": "601036", - "wifi_name_5g": "Wifi-Main-Network", - "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", - "wifi_security_5g": "401027", - "wifi_channel_5g": "36", - "wifi_bandwidth_5g": "4803", - "guest_wifi_status_5g": "601037", - "guest_wifi_name_5g": "Wifi-Guest", - "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", - "guest_wifi_channel_5g": "N/A", - "guest_wifi_security_5g": "401027", - "guest_wifi_ip_5g": "192.168.2.1", - "guest_wifi_subnet_addr_5g": "255.255.255.0", - "guest_wifi_dhcp_server_5g": "192.168.2.1", -} +DEVICE_2_HOST = "LanDevice1" +DEVICE_2_MAC = "yy:yy:yy:yy:yy:yy" diff --git a/tests/components/vodafone_station/fixtures/get_sensor_data.json b/tests/components/vodafone_station/fixtures/get_sensor_data.json new file mode 100644 index 00000000000..6a6229ebd18 --- /dev/null +++ b/tests/components/vodafone_station/fixtures/get_sensor_data.json @@ -0,0 +1,81 @@ +{ + "sys_serial_number": "m123456789", + "sys_firmware_version": "XF6_4.0.05.04", + "sys_bootloader_version": "0220", + "sys_hardware_version": "RHG3006 v1", + "omci_software_version": "\t\t1.0.0.1_41032\t\t\n", + "sys_uptime": "12:16:41", + "sys_cpu_usage": "97%", + "sys_reboot_cause": "Web Reboot", + "sys_memory_usage": "51.94%", + "sys_wireless_driver_version": "17.10.188.75;17.10.188.75", + "sys_wireless_driver_version_5g": "17.10.188.75;17.10.188.75", + "vf_internet_key_online_since": "", + "vf_internet_key_ip_addr": "0.0.0.0", + "vf_internet_key_system": "0.0.0.0", + "vf_internet_key_mode": "Auto", + "sys_voip_version": "v02.01.00_01.13a\n", + "sys_date_time": "20.10.2024 | 03:44 pm", + "sys_build_time": "Sun Jun 23 17:55:49 CST 2024\n", + "sys_model_name": "RHG3006", + "inter_ip_address": "1.1.1.1", + "inter_gateway": "1.1.1.2", + "inter_primary_dns": "1.1.1.3", + "inter_secondary_dns": "1.1.1.4", + "inter_firewall": "601036", + "inter_wan_ip_address": "1.1.1.1", + "inter_ipv6_link_local_address": "", + "inter_ipv6_link_global_address": "", + "inter_ipv6_gateway": "", + "inter_ipv6_prefix_delegation": "", + "inter_ipv6_dns_address1": "", + "inter_ipv6_dns_address2": "", + "lan_ip_network": "192.168.0.1/24", + "lan_default_gateway": "192.168.0.1", + "lan_subnet_address_subnet1": "", + "lan_mac_address": "11:22:33:44:55:66", + "lan_dhcp_server": "601036", + "lan_dhcpv6_server": "601036", + "lan_router_advertisement": "601036", + "lan_ipv6_default_gateway": "fe80::1", + "lan_port1_switch_mode": "1301722", + "lan_port2_switch_mode": "1301722", + "lan_port3_switch_mode": "1301722", + "lan_port4_switch_mode": "1301722", + "lan_port1_switch_speed": "10", + "lan_port2_switch_speed": "100", + "lan_port3_switch_speed": "1000", + "lan_port4_switch_speed": "1000", + "lan_port1_switch_status": "1301724", + "lan_port2_switch_status": "1301724", + "lan_port3_switch_status": "1301724", + "lan_port4_switch_status": "1301724", + "wifi_status": "601036", + "wifi_name": "Wifi-Main-Network", + "wifi_mac_address": "AA:BB:CC:DD:EE:FF", + "wifi_security": "401027", + "wifi_channel": "8", + "wifi_bandwidth": "573", + "guest_wifi_status": "601037", + "guest_wifi_name": "Wifi-Guest", + "guest_wifi_mac_addr": "AA:BB:CC:DD:EE:GG", + "guest_wifi_security": "401027", + "guest_wifi_channel": "N/A", + "guest_wifi_ip": "192.168.2.1", + "guest_wifi_subnet_addr": "255.255.255.0", + "guest_wifi_dhcp_server": "192.168.2.1", + "wifi_status_5g": "601036", + "wifi_name_5g": "Wifi-Main-Network", + "wifi_mac_address_5g": "AA:BB:CC:DD:EE:HH", + "wifi_security_5g": "401027", + "wifi_channel_5g": "36", + "wifi_bandwidth_5g": "4803", + "guest_wifi_status_5g": "601037", + "guest_wifi_name_5g": "Wifi-Guest", + "guest_wifi_mac_addr_5g": "AA:BB:CC:DD:EE:II", + "guest_wifi_channel_5g": "N/A", + "guest_wifi_security_5g": "401027", + "guest_wifi_ip_5g": "192.168.2.1", + "guest_wifi_subnet_addr_5g": "255.255.255.0", + "guest_wifi_dhcp_server_5g": "192.168.2.1" +} diff --git a/tests/components/vodafone_station/snapshots/test_button.ambr b/tests/components/vodafone_station/snapshots/test_button.ambr new file mode 100644 index 00000000000..736f590241a --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_button.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_all_entities[button.vodafone_station_m123456789_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'm123456789_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.vodafone_station_m123456789_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Vodafone Station (m123456789) Restart', + }), + 'context': , + 'entity_id': 'button.vodafone_station_m123456789_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..7f98aad1405 --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[device_tracker.landevice1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.landevice1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LanDevice1', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_tracker', + 'unique_id': 'yy:yy:yy:yy:yy:yy', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.landevice1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LanDevice1', + 'host_name': 'LanDevice1', + 'ip': '192.168.1.11', + 'mac': 'yy:yy:yy:yy:yy:yy', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.landevice1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_all_entities[device_tracker.wifidevice0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.wifidevice0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'WifiDevice0', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_tracker', + 'unique_id': 'xx:xx:xx:xx:xx:xx', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_tracker.wifidevice0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WifiDevice0', + 'host_name': 'WifiDevice0', + 'ip': '192.168.1.10', + 'mac': 'xx:xx:xx:xx:xx:xx', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.wifidevice0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index c258b14dc2d..be2956e0aab 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -9,6 +9,12 @@ 'hostname': 'WifiDevice0', 'type': 'laptop', }), + dict({ + 'connected': False, + 'connection_type': 'lan', + 'hostname': 'LanDevice1', + 'type': 'desktop', + }), ]), 'last_exception': None, 'last_update success': True, @@ -35,6 +41,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..169ee92a24b --- /dev/null +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -0,0 +1,251 @@ +# serializer version: 1 +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active connection', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_connection', + 'unique_id': 'm123456789_inter_ip_address', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_active_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vodafone Station (m123456789) Active connection', + 'options': list([ + 'dsl', + 'fiber', + 'internet_key', + ]), + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_active_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_cpu_usage', + 'unique_id': 'm123456789_sys_cpu_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) CPU usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Memory usage', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_memory_usage', + 'unique_id': 'm123456789_sys_memory_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Memory usage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.94', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reboot cause', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_reboot_cause', + 'unique_id': 'm123456789_sys_reboot_cause', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_reboot_cause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vodafone Station (m123456789) Reboot cause', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_reboot_cause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Web Reboot', + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'vodafone_station', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sys_uptime', + 'unique_id': 'm123456789_sys_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vodafone_station_m123456789_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vodafone Station (m123456789) Uptime', + }), + 'context': , + 'entity_id': 'sensor.vodafone_station_m123456789_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-19T20:19:00+00:00', + }) +# --- diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index 8b9b0753caa..d5f377d3f6f 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -1,56 +1,48 @@ """Tests for Vodafone Station button platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers import entity_registry as er -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY, SERIAL +from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_button(hass: HomeAssistant, entity_registry: EntityRegistry) -> None: +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.BUTTON] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_pressing_button( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test device restart button.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.restart_router", - ) as mock_router_restart, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_id = f"button.vodafone_station_{SERIAL}_restart" - - # restart button - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_UNKNOWN - - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == f"{SERIAL}_reboot" - - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_router_restart.call_count == 1 + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + mock_vodafone_station_router.restart_router.assert_called_once() diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 3a54f250871..68f8247bdf9 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -1,8 +1,13 @@ """Tests for Vodafone Station config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiovodafone import exceptions as aiovodafone_exceptions +from aiovodafone import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + ModelNotSupported, +) import pytest from homeassistant.components.device_tracker import CONF_CONSIDER_HOME @@ -12,39 +17,36 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_USER_DATA - from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +async def test_user( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: """Test starting a flow by user.""" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry" - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_USERNAME] == "fake_username" - assert result["data"][CONF_PASSWORD] == "fake_password" - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + assert not result["result"].unique_id assert mock_setup_entry.called @@ -52,14 +54,20 @@ async def test_user(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), - (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -68,178 +76,153 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with patch( - "aiovodafone.api.VodafoneStationSercommApi.login", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA - ) + mock_vodafone_station_router.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "fake_host" - assert result2["data"] == { - "host": "fake_host", - "username": "fake_username", - "password": "fake_password", - } + mock_vodafone_station_router.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "fake_host" + assert result["data"] == { + "host": "fake_host", + "username": "fake_username", + "password": "fake_password", + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_duplicate_entry( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user with a duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( ("side_effect", "error"), [ - (aiovodafone_exceptions.CannotConnect, "cannot_connect"), - (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), - (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - - result = await mock_config.start_reauth_flow(hass) + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - side_effect=side_effect, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "other_fake_password", - }, - ) + mock_vodafone_station_router.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} - # Should be recoverable after hits error - with ( - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", - return_value={ - "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", - "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", - }, - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", - ), - patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", - ), - patch( - "homeassistant.components.vodafone_station.async_setup_entry", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + mock_vodafone_station_router.login.side_effect = None - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test options flow.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - mock_config.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(mock_config.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CONSIDER_HOME: 37, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/vodafone_station/test_coordinator.py b/tests/components/vodafone_station/test_coordinator.py new file mode 100644 index 00000000000..1a9470245c7 --- /dev/null +++ b/tests/components/vodafone_station/test_coordinator.py @@ -0,0 +1,68 @@ +"""Define tests for the Vodafone Station coordinator.""" + +import logging +from unittest.mock import AsyncMock + +from aiovodafone import VodafoneStationDevice +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.vodafone_station.const import DOMAIN, SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_HOST, DEVICE_2_MAC + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_coordinator_device_cleanup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Device cleanup on coordinator update.""" + + caplog.set_level(logging.DEBUG) + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, DEVICE_1_MAC)}, + name=DEVICE_1_HOST, + ) + assert device is not None + + device_tracker = f"device_tracker.{DEVICE_1_HOST}" + + state = hass.states.get(device_tracker) + assert state is not None + + mock_vodafone_station_router.get_devices_data.return_value = { + DEVICE_2_MAC: VodafoneStationDevice( + connected=True, + connection_type="lan", + ip_address="192.168.1.11", + name=DEVICE_2_HOST, + mac=DEVICE_2_MAC, + type="desktop", + wifi="", + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(device_tracker) + assert state is None + assert f"Skipping entity {DEVICE_2_HOST}" in caplog.text + + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_1_MAC)}) + assert device is None + assert f"Removing device: {DEVICE_1_HOST}" in caplog.text diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index 1434d682ec9..e172fa76de5 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -1,43 +1,59 @@ """Define tests for the Vodafone Station device tracker.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.vodafone_station.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.vodafone_station.const import SCAN_INTERVAL from homeassistant.components.vodafone_station.coordinator import CONSIDER_HOME_SECONDS -from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .const import DEVICE_1, DEVICE_1_MAC, DEVICE_DATA_QUERY, MOCK_USER_DATA +from . import setup_integration +from .const import DEVICE_1_HOST, DEVICE_1_MAC -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_coordinator_consider_home( +async def test_all_entities( hass: HomeAssistant, - freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_consider_home( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test if device is considered not_home when disconnected.""" + await setup_integration(hass, mock_config_entry) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - device_tracker = f"device_tracker.vodafone_station_{DEVICE_1_MAC.replace(":", "_")}" - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + device_tracker = f"device_tracker.{DEVICE_1_HOST}" state = hass.states.get(device_tracker) assert state assert state.state == STATE_HOME - DEVICE_1[DEVICE_1_MAC].connected = False - DEVICE_DATA_QUERY.update(DEVICE_1) - mock_vodafone_station_router.get_devices_data.return_value = DEVICE_DATA_QUERY + mock_vodafone_station_router.get_devices_data.return_value[ + DEVICE_1_MAC + ].connected = False freezer.tick(SCAN_INTERVAL + CONSIDER_HOME_SECONDS) async_fire_time_changed(hass) diff --git a/tests/components/vodafone_station/test_diagnostics.py b/tests/components/vodafone_station/test_diagnostics.py index 02918d81912..5a4a46ce693 100644 --- a/tests/components/vodafone_station/test_diagnostics.py +++ b/tests/components/vodafone_station/test_diagnostics.py @@ -2,16 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.vodafone_station.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import DEVICE_DATA_QUERY, MOCK_USER_DATA, SENSOR_DATA_QUERY +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -20,29 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch("aiovodafone.api.VodafoneStationSercommApi.login"), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_devices_data", - return_value=DEVICE_DATA_QUERY, - ), - patch( - "aiovodafone.api.VodafoneStationSercommApi.get_sensor_data", - return_value=SENSOR_DATA_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py new file mode 100644 index 00000000000..12b3c3dce8f --- /dev/null +++ b/tests/components/vodafone_station/test_init.py @@ -0,0 +1,33 @@ +"""Tests for Vodafone Station init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_reload_config_entry_with_options( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the the config entry is reloaded with options.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } diff --git a/tests/components/vodafone_station/test_sensor.py b/tests/components/vodafone_station/test_sensor.py index 3a63566b5dc..ddf97824c75 100644 --- a/tests/components/vodafone_station/test_sensor.py +++ b/tests/components/vodafone_station/test_sensor.py @@ -1,21 +1,37 @@ """Tests for Vodafone Station sensor platform.""" -from copy import deepcopy -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiovodafone import CannotAuthenticate +from aiovodafone.exceptions import AlreadyLogged, CannotConnect from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.vodafone_station.const import ( - DOMAIN, - LINE_TYPES, - SCAN_INTERVAL, -) +from homeassistant.components.vodafone_station.const import LINE_TYPES, SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .const import MOCK_USER_DATA, SENSOR_DATA_QUERY +from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.vodafone_station.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -30,26 +46,22 @@ async def test_active_connection_type( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vodafone_station_router: AsyncMock, - connection_type, - index, + mock_config_entry: MockConfigEntry, + connection_type: str, + index: int, ) -> None: """Test device connection type.""" + await setup_integration(hass, mock_config_entry) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - active_connection_entity = f"sensor.vodafone_station_{SENSOR_DATA_QUERY['sys_serial_number']}_active_connection" - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + active_connection_entity = "sensor.vodafone_station_m123456789_active_connection" state = hass.states.get(active_connection_entity) assert state - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN - sensor_data = deepcopy(SENSOR_DATA_QUERY) - sensor_data[connection_type] = "1.1.1.1" - mock_vodafone_station_router.get_sensor_data.return_value = sensor_data + mock_vodafone_station_router.get_sensor_data.return_value[connection_type] = ( + "1.1.1.1" + ) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -65,19 +77,13 @@ async def test_uptime( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test device uptime shift.""" - - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) uptime = "2024-11-19T20:19:00+00:00" - uptime_entity = ( - f"sensor.vodafone_station_{SENSOR_DATA_QUERY['sys_serial_number']}_uptime" - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + uptime_entity = "sensor.vodafone_station_m123456789_uptime" state = hass.states.get(uptime_entity) assert state @@ -92,3 +98,32 @@ async def test_uptime( state = hass.states.get(uptime_entity) assert state assert state.state == uptime + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotAuthenticate, + AlreadyLogged, + ConnectionResetError, + ], +) +async def test_coordinator_client_connector_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test ClientConnectorError on coordinator update.""" + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.get_devices_data.side_effect = side_effect + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.vodafone_station_m123456789_uptime") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 776c0ac153a..e6a30d7fac2 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -200,7 +200,7 @@ async def test_service_say_error( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -234,7 +234,7 @@ async def test_service_say_timeout( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -273,7 +273,7 @@ async def test_service_say_error_msg( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 99707297230..d47db58d585 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -57,6 +57,7 @@ def call_info() -> CallInfo: """Fake call info.""" return CallInfo( caller_endpoint=get_sip_endpoint("192.168.1.210", 5060), + local_endpoint=get_sip_endpoint("192.168.1.10", 5060), caller_rtp_port=5004, server_ip="192.168.1.10", headers={ diff --git a/tests/components/voip/test_config_flow.py b/tests/components/voip/test_config_flow.py index 1b7aaad7c03..05f14afa4e7 100644 --- a/tests/components/voip/test_config_flow.py +++ b/tests/components/voip/test_config_flow.py @@ -80,3 +80,30 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options == {"sip_port": 5061} + + # Manual with user + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5061, "sip_user": "HA"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5061, "sip_user": "HA"} + + # Manual remove user + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + + assert config_entry.options == {"sip_port": 5061, "sip_user": "HA"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5060, "sip_user": ""}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5060} diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index 55359b8407d..4e2e129d4be 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -2,12 +2,15 @@ from __future__ import annotations +import pytest from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry async def test_device_registry_info( @@ -21,10 +24,10 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device is not None - assert device.name == call_info.caller_ip + assert device.name == call_info.caller_endpoint.host assert device.manufacturer == "Grandstream" assert device.model == "HT801" assert device.sw_version == "1.0.17.5" @@ -36,7 +39,7 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.sw_version == "2.0.0.0" @@ -53,7 +56,7 @@ async def test_device_registry_info_from_unknown_phone( assert not voip_device.async_allow_call(hass) device = device_registry.async_get_device( - identifiers={(DOMAIN, call_info.caller_ip)} + identifiers={(DOMAIN, call_info.caller_endpoint.uri)} ) assert device.manufacturer is None assert device.model == "Unknown" @@ -76,3 +79,53 @@ async def test_remove_device_registry_entry( assert hass.states.get("switch.192_168_1_210_allow_calls") is None assert voip_device.voip_id not in voip_devices.devices + + +@pytest.fixture +async def legacy_dev_reg_entry( + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + call_info: CallInfo, +) -> None: + """Fixture to run before we set up the VoIP integration via fixture.""" + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, call_info.caller_ip)}, + ) + entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{call_info.caller_ip}-allow_calls", + device_id=device.id, + config_entry=config_entry, + ) + return device + + +async def test_device_registry_migration( + hass: HomeAssistant, + legacy_dev_reg_entry: dr.DeviceEntry, + voip_devices: VoIPDevices, + call_info: CallInfo, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry migrates old devices.""" + voip_device = voip_devices.async_get_or_create(call_info) + new_id = call_info.caller_endpoint.uri + assert voip_device.voip_id == new_id + + device = device_registry.async_get_device(identifiers={(DOMAIN, new_id)}) + assert device is not None + assert device.id == legacy_dev_reg_entry.id + assert device.identifiers == {(DOMAIN, new_id)} + assert device.name == call_info.caller_endpoint.host + assert device.manufacturer == "Grandstream" + assert device.model == "HT801" + assert device.sw_version == "1.0.17.5" + + assert ( + entity_registry.async_get_entity_id("switch", DOMAIN, f"{new_id}-allow_calls") + is not None + ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 17af2748c1c..3e3e5337417 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -16,7 +16,7 @@ from homeassistant.components.assist_satellite import AssistSatelliteEntity # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.components.voip import HassVoipDatagramProtocol +from homeassistant.components.voip import DOMAIN, HassVoipDatagramProtocol from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol @@ -844,3 +844,330 @@ async def test_pipeline_error( assert sum(played_audio_bytes) > 0 assert played_audio_bytes == snapshot() + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_voip_id_is_ip_address( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when VoIP is an IP address instead of a SIP header.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch.object(voip_device, "voip_id", "192.168.68.10"), + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + assert ( + mock_protocol.outgoing_call.call_args.kwargs["destination"].host + == "192.168.68.10" + ) + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation_user_doesnt_pick_up( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation when the user doesn't pick up.""" + assert await async_setup_component(hass, "voip", {}) + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + pipeline_started = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + conversation_extra_system_prompt: str | None = None, + **kwargs, + ): + # System prompt should be not be set due to timeout (user not picking up) + assert conversation_extra_system_prompt is None + + pipeline_started.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_start_conversation", + side_effect=TimeoutError, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="test media id", + ), + ): + satellite.transport = Mock() + + # Error should clear system prompt + with pytest.raises(TimeoutError): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_message": "test announcement", + "extra_system_prompt": "test prompt", + }, + blocking=True, + ) + + # Trigger a pipeline so we can check if the system prompt was cleared + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await pipeline_started.wait() diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 9c3708f970c..85e9e250ab9 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -4,11 +4,11 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.volumio.config_flow import CannotConnectError from homeassistant.components.volumio.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ TEST_CONNECTION = { } -TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( +TEST_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("1.1.1.1"), ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index f9f922b35d4..65be23fc168 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -4,8 +4,7 @@ import pytest import voluptuous as vol from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import CONF_SUBSCRIPTION -import homeassistant.components.vultr.sensor as vultr +from homeassistant.components.vultr import CONF_SUBSCRIPTION, sensor as vultr from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index cdaf7e0e3f0..e6e8ff72a6d 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from .common import mock_wake_word_entity_platform @@ -143,7 +143,7 @@ async def mock_config_entry_setup( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 3d00f1cff26..08e58a74524 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -36,11 +36,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Visbility using nephelometry', + 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_visbility_using_nephelometry', + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 09a0a711582..191acdf24f9 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -2,19 +2,20 @@ from __future__ import annotations +from typing import Any from unittest import mock from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( DOMAIN, SERVICE_SET_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, WaterHeaterEntityDescription, - WaterHeaterEntityEntityDescription, WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -22,13 +23,14 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, MockModule, MockPlatform, async_mock_service, + import_and_test_deprecated_constant, mock_integration, mock_platform, ) @@ -143,7 +145,7 @@ async def test_operation_mode_validation( async def async_setup_entry_water_heater_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test water_heater platform via config entry.""" async_add_entities([water_heater_entity]) @@ -209,12 +211,27 @@ async def test_operation_mode_validation( @pytest.mark.parametrize( - ("class_name", "expected_log"), - [(WaterHeaterEntityDescription, False), (WaterHeaterEntityEntityDescription, True)], + ("constant_name", "replacement_name", "replacement"), + [ + ( + "WaterHeaterEntityEntityDescription", + "WaterHeaterEntityDescription", + WaterHeaterEntityDescription, + ), + ], ) -async def test_deprecated_entity_description( - caplog: pytest.LogCaptureFixture, class_name: type, expected_log: bool +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, ) -> None: - """Test deprecated WaterHeaterEntityEntityDescription logs warning.""" - class_name(key="test") - assert ("is a deprecated class" in caplog.text) is expected_log + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + water_heater, + constant_name, + replacement_name, + replacement, + "2026.1", + ) diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index 479a879a583..b4b6c4ee0a4 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -43,7 +44,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:58+00:00', }) # --- # name: test_sensor[sensor.sonic_power_supply_mode-entry] @@ -59,6 +60,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -262,6 +267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -313,6 +319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -364,6 +371,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -415,6 +423,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -464,6 +473,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +511,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2021-01-09T11:59:59+00:00', + 'state': '2021-01-09T11:59:57+00:00', }) # --- diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 58632c7548b..78e375857ed 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -140,11 +140,11 @@ async def test_power_supply_webhook( power_supply_change_data = { "type": "power-supply-changed", - "data": {"supply": "external"}, + "data": {"supply": "external_battery"}, } client = await hass_client_no_auth() await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "external" + assert hass.states.get(entity_id).state == "battery_external" diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index 0c137acc36b..3cc5e1d6f66 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -27,6 +27,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py index 2dbffbbd617..301e055129d 100644 --- a/tests/components/weather/__init__.py +++ b/tests/components/weather/__init__.py @@ -21,7 +21,7 @@ from homeassistant.components.weather import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -90,7 +90,7 @@ async def create_entity( async def async_setup_entry_weather_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test weather platform via config entry.""" async_add_entities([weather_entity]) diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index 95be86664a2..c06229302c5 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -117,6 +119,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -172,6 +175,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +231,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -277,6 +282,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -327,6 +333,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +384,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +435,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +486,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -535,6 +545,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -593,6 +604,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -648,6 +660,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -703,6 +716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +772,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr index 569b744529c..0b0d66c34a7 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py new file mode 100644 index 00000000000..33e0222fb34 --- /dev/null +++ b/tests/components/webdav/__init__.py @@ -0,0 +1 @@ +"""Tests for the WebDAV integration.""" diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py new file mode 100644 index 00000000000..4fdd6fb7870 --- /dev/null +++ b/tests/components/webdav/conftest.py @@ -0,0 +1,65 @@ +"""Common fixtures for the WebDAV tests.""" + +from collections.abc import AsyncIterator, Generator +from json import dumps +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.webdav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webdav.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + +async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: + """Mock the download function.""" + if path.endswith(".json"): + yield dumps(BACKUP_METADATA).encode() + + yield b"backup data" + + +@pytest.fixture(name="webdav_client") +def mock_webdav_client() -> Generator[AsyncMock]: + """Mock the aiowebdav client.""" + with ( + patch( + "homeassistant.components.webdav.helpers.Client", + autospec=True, + ) as mock_webdav_client, + ): + mock = mock_webdav_client.return_value + mock.check.return_value = True + mock.mkdir.return_value = True + mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + mock.download_iter.side_effect = _download_mock + mock.upload_iter.return_value = None + mock.clean.return_value = None + yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py new file mode 100644 index 00000000000..8d6b8ad67d7 --- /dev/null +++ b/tests/components/webdav/const.py @@ -0,0 +1,33 @@ +"""Constants for WebDAV tests.""" + +from aiowebdav2 import Property + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "protected": False, + "size": 34519040, +} + +MOCK_LIST_WITH_PROPERTIES = { + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ + Property( + namespace="https://home-assistant.io", + name="backup_id", + value="23e64aec", + ), + Property( + namespace="https://home-assistant.io", + name="metadata_version", + value="1", + ), + ], +} diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py new file mode 100644 index 00000000000..c20e73cc786 --- /dev/null +++ b/tests/components/webdav/test_backup.py @@ -0,0 +1,353 @@ +"""Test the backups for WebDAV.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import Mock, patch + +from aiowebdav2 import Property +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.webdav.backup import async_register_backup_agents_listener +from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> AsyncGenerator[None]: + """Set up webdav integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + async_initialize_backup(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "webdav.01JKXV07ASC62D620DGYNG2R8H": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert webdav_client.clean.call_count == 2 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert webdav_client.upload_iter.call_count == 2 + assert webdav_client.set_property_batch.call_count == 1 + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we get not found on a not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + WebDavError("Unknown path"), + "Backup operation failed: Unknown path", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + webdav_client.clean.side_effect = side_effect + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.entry_id}": error} + } + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + webdav_client.list_with_properties.return_value = {} + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test backup not found.""" + webdav_client.list_with_properties.return_value = [] + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_raises_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we raise on 403.""" + webdav_client.list_with_properties.side_effect = UnauthorizedError( + "https://webdav.example.com" + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.entry_id}": "Authentication error" + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = AsyncMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + # make sure it's the last listener + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None + + +async def test_metadata_misses_backup_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test getting a backup when metadata has backup id property.""" + MOCK_LIST_WITH_PROPERTIES[ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" + ] = [ + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ) + ] + webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py new file mode 100644 index 00000000000..eb887edb1a1 --- /dev/null +++ b/tests/components/webdav/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the WebDAV config flow.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import UnauthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test we get the form and create a entry on success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert result["data"] == { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + } + assert len(webdav_client.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test to handle exceptions.""" + webdav_client.check.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # reset and test for success + webdav_client.check.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_unauthorized( + hass: HomeAssistant, + webdav_client: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test to handle unauthorized.""" + webdav_client.check.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # reset and test for success + webdav_client.check.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> None: + """Test we get the form and create a entry on success.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index 8299b0eafba..c64fa212a98 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -253,6 +253,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index 6af768d63a8..a2068f662ba 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -155,6 +158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -269,6 +274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,6 +332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -376,6 +383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -426,6 +434,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +485,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -525,6 +535,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -623,6 +635,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -680,6 +693,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -737,6 +751,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -794,6 +809,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -844,6 +860,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -894,6 +911,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -944,6 +962,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -993,6 +1012,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1042,6 +1062,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1091,6 +1112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1148,6 +1170,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1262,6 +1286,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1319,6 +1344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1376,6 +1402,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1433,6 +1460,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1482,6 +1510,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1531,6 +1560,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1580,6 +1610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1637,6 +1668,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1694,6 +1726,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1751,6 +1784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/webostv/__init__.py b/tests/components/webostv/__init__.py index d6c096f9d3a..d9a0a135023 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -1,9 +1,8 @@ -"""Tests for the WebOS TV integration.""" +"""Tests for the LG webOS TV integration.""" from homeassistant.components.webostv.const import DOMAIN from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .const import CLIENT_KEY, FAKE_UUID, HOST, TV_NAME @@ -25,11 +24,7 @@ async def setup_webostv( ) entry.add_to_hass(hass) - await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: HOST}}, - ) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index a30ae933cca..7fbd8d667e2 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -1,13 +1,22 @@ -"""Common fixtures and objects for the LG webOS integration tests.""" +"""Common fixtures and objects for the LG webOS TV integration tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from aiowebostv import WebOsTvInfo, WebOsTvState import pytest from homeassistant.components.webostv.const import LIVE_TV_APP_ID -from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS +from .const import ( + CHANNEL_1, + CHANNEL_2, + CLIENT_KEY, + FAKE_UUID, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, +) @pytest.fixture @@ -22,30 +31,40 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="client") def client_fixture(): """Patch of client library for tests.""" - with patch( - "homeassistant.components.webostv.WebOsClient", autospec=True - ) as mock_client_class: + with ( + patch( + "homeassistant.components.webostv.WebOsClient", autospec=True + ) as mock_client_class, + patch( + "homeassistant.components.webostv.config_flow.WebOsClient", + new=mock_client_class, + ), + ): client = mock_client_class.return_value - client.hello_info = {"deviceUUID": FAKE_UUID} - client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": "TVFAKE"} + client.tv_info = WebOsTvInfo( + hello={"deviceUUID": FAKE_UUID}, + system={"modelName": TV_MODEL, "serialNumber": "1234567890"}, + software={"major_ver": "major", "minor_ver": "minor"}, + ) client.client_key = CLIENT_KEY - client.apps = MOCK_APPS - client.inputs = MOCK_INPUTS - client.current_app_id = LIVE_TV_APP_ID + client.tv_state = WebOsTvState( + apps=MOCK_APPS, + inputs=MOCK_INPUTS, + current_app_id=LIVE_TV_APP_ID, + channels=[CHANNEL_1, CHANNEL_2], + current_channel=CHANNEL_1, + volume=37, + sound_output="speaker", + muted=False, + is_on=True, + media_state=[{"playState": ""}], + ) - client.channels = [CHANNEL_1, CHANNEL_2] - client.current_channel = CHANNEL_1 - - client.volume = 37 - client.sound_output = "speaker" - client.muted = False - client.is_on = True client.is_registered = Mock(return_value=True) client.is_connected = Mock(return_value=True) async def mock_state_update_callback(): - await client.register_state_update_callback.call_args[0][0](client) + await client.register_state_update_callback.call_args[0][0](client.tv_state) client.mock_state_update = AsyncMock(side_effect=mock_state_update_callback) diff --git a/tests/components/webostv/const.py b/tests/components/webostv/const.py index afaed224e83..a63a4fe3289 100644 --- a/tests/components/webostv/const.py +++ b/tests/components/webostv/const.py @@ -1,11 +1,13 @@ -"""Constants for LG webOS Smart TV tests.""" +"""Constants for LG webOS TV tests.""" from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.webostv.const import LIVE_TV_APP_ID +from homeassistant.util import slugify FAKE_UUID = "some-fake-uuid" -TV_NAME = "fake_webos" -ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}" +TV_MODEL = "MODEL" +TV_NAME = f"LG webOS TV {TV_MODEL}" +ENTITY_ID = f"{MP_DOMAIN}.{slugify(TV_NAME)}" HOST = "1.2.3.4" CLIENT_KEY = "some-secret" diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..2febee15deb --- /dev/null +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'client': dict({ + 'is_connected': True, + 'is_registered': True, + 'tv_info': dict({ + 'hello': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'software': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'system': dict({ + 'modelName': 'MODEL', + 'serialNumber': '1234567890', + }), + }), + 'tv_state': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'channel_info': None, + 'channels': list([ + dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + dict({ + 'channelId': 'ch2id', + 'channelName': 'Channel Name 2', + 'channelNumber': '20', + }), + ]), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_on': True, + 'is_screen_on': False, + 'media_state': list([ + dict({ + 'playState': '', + }), + ]), + 'muted': False, + 'power_state': dict({ + }), + 'sound_output': 'speaker', + 'volume': 37, + }), + }), + 'entry': dict({ + 'data': dict({ + 'client_secret': '**REDACTED**', + 'host': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'webostv', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'LG webOS TV MODEL', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..9c097b166ec --- /dev/null +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -0,0 +1,78 @@ +# serializer version: 1 +# name: test_command + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'muted': False, + 'returnValue': True, + 'scenario': 'mastervolume_tv_speaker_ext', + 'volume': 1, + }), + }) +# --- +# name: test_entity_attributes + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV MODEL', + 'is_volume_muted': False, + 'media_content_type': , + 'media_title': 'Channel 1', + 'sound_output': 'speaker', + 'source': 'Live TV', + 'source_list': list([ + 'Input01', + 'Input02', + 'Live TV', + ]), + 'supported_features': , + 'volume_level': 0.37, + }), + 'context': , + 'entity_id': 'media_player.lg_webos_tv_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_attributes.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'webostv', + 'some-fake-uuid', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'LG', + 'model': 'MODEL', + 'model_id': None, + 'name': 'LG webOS TV MODEL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': 'major.minor', + 'via_device_id': None, + }) +# --- +# name: test_select_sound_output + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'method': 'setSystemSettings', + 'returnValue': True, + }), + }) +# --- diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index cc335a4fb41..564ff9afa9b 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,63 +1,48 @@ -"""Test the WebOS Tv config flow.""" - -import dataclasses -from unittest.mock import Mock +"""Test the LG webOS TV config flow.""" from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID from homeassistant.config_entries import SOURCE_SSDP -from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from . import setup_webostv -from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME +from .const import ( + CLIENT_KEY, + FAKE_UUID, + HOST, + MOCK_APPS, + MOCK_INPUTS, + TV_MODEL, + TV_NAME, +) pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_USER_CONFIG = { - CONF_HOST: HOST, - CONF_NAME: TV_NAME, -} +MOCK_USER_CONFIG = {CONF_HOST: HOST} -MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( +MOCK_DISCOVERY_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=f"http://{HOST}", upnp={ - ssdp.ATTR_UPNP_FRIENDLY_NAME: "LG Webostv", - ssdp.ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", + ATTR_UPNP_FRIENDLY_NAME: f"[LG] webOS TV {TV_MODEL}", + ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", }, ) async def test_form(hass: HomeAssistant, client) -> None: - """Test we get the form.""" - assert client - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: config_entries.SOURCE_USER}, - data=MOCK_USER_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pairing" - + """Test successful user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -72,10 +57,10 @@ async def test_form(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID @pytest.mark.parametrize( @@ -99,8 +84,8 @@ async def test_options_flow_live_tv_in_apps( hass: HomeAssistant, client, apps, inputs ) -> None: """Test options config flow Live TV found in apps.""" - client.apps = apps - client.inputs = inputs + client.tv_state.apps = apps + client.tv_state.inputs = inputs entry = await setup_webostv(hass) result = await hass.config_entries.options.async_init(entry.entry_id) @@ -109,26 +94,52 @@ async def test_options_flow_live_tv_in_apps( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SOURCES: ["Live TV", "Input01", "Input02"]}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] -async def test_options_flow_cannot_retrieve(hass: HomeAssistant, client) -> None: - """Test options config flow cannot retrieve sources.""" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionResetError, "cannot_connect"), + ], +) +async def test_options_flow_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test options config flow errors.""" entry = await setup_webostv(hass) - client.connect = Mock(side_effect=ConnectionRefusedError()) + client.connect.side_effect = side_effect result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve"} + assert result["errors"] == {"base": error} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SOURCES: ["Input01", "Input02"]}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"][CONF_SOURCES] == ["Input01", "Input02"] async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: @@ -139,14 +150,22 @@ async def test_form_cannot_connect(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=ConnectionRefusedError()) - result2 = await hass.config_entries.flow.async_configure( + client.connect.side_effect = ConnectionResetError + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_form_pairexception(hass: HomeAssistant, client) -> None: @@ -157,20 +176,27 @@ async def test_form_pairexception(hass: HomeAssistant, client) -> None: data=MOCK_USER_CONFIG, ) - client.connect = Mock(side_effect=WebOsTvPairError("error")) - result2 = await hass.config_entries.flow.async_configure( + client.connect.side_effect = WebOsTvPairError + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "error_pairing" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "error_pairing"} + + # recover + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME async def test_entry_already_configured(hass: HomeAssistant, client) -> None: """Test entry already configured.""" await setup_webostv(hass) - assert client result = await hass.config_entries.flow.async_init( DOMAIN, @@ -184,8 +210,6 @@ async def test_entry_already_configured(hass: HomeAssistant, client) -> None: async def test_form_ssdp(hass: HomeAssistant, client) -> None: """Test that the ssdp confirmation form is served.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) @@ -194,11 +218,18 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TV_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID + async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: """Test abort if ssdp paring is already in progress.""" - assert client - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -209,39 +240,20 @@ async def test_ssdp_in_progress(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "pairing" - result2 = await hass.config_entries.flow.async_init( + # Start another ssdp flow to make sure it aborts as already in progress + result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" - - -async def test_ssdp_not_update_uuid(hass: HomeAssistant, client) -> None: - """Test that ssdp not updates different host.""" - entry = await setup_webostv(hass, None) - assert client - assert entry.unique_id is None - - discovery_info = dataclasses.replace(MOCK_DISCOVERY_INFO) - discovery_info.ssdp_location = "http://1.2.3.5" - - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "pairing" - assert entry.unique_id is None + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: """Test abort if uuid is already configured, verify host update.""" - entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:]) - assert client - assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ssdp.ATTR_UPNP_UDN][5:] + entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:]) + assert entry.unique_id == MOCK_DISCOVERY_INFO.upnp[ATTR_UPNP_UDN][5:] assert entry.data[CONF_HOST] == HOST result = await hass.config_entries.flow.async_init( @@ -253,11 +265,9 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - user_config = { - CONF_HOST: "new_host", - CONF_NAME: TV_NAME, - } + user_config = {CONF_HOST: "new_host"} + # Start another flow to make sure it aborts and updates host result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, @@ -272,19 +282,14 @@ async def test_form_abort_uuid_configured(hass: HomeAssistant, client) -> None: result["flow_id"], user_input={} ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == "new_host" -async def test_reauth_successful( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_successful(hass: HomeAssistant, client) -> None: """Test that the reauthorization is successful.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -295,7 +300,7 @@ async def test_reauth_successful( assert result["step_id"] == "reauth_confirm" assert entry.data[CONF_CLIENT_SECRET] == CLIENT_KEY - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -306,18 +311,15 @@ async def test_reauth_successful( @pytest.mark.parametrize( - ("side_effect", "reason"), + ("side_effect", "error"), [ (WebOsTvPairError, "error_pairing"), - (ConnectionRefusedError, "reauth_unsuccessful"), + (ConnectionResetError, "cannot_connect"), ], ) -async def test_reauth_errors( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch, side_effect, reason -) -> None: +async def test_reauth_errors(hass: HomeAssistant, client, side_effect, error) -> None: """Test reauthorization errors.""" entry = await setup_webostv(hass) - assert client result = await entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" @@ -327,10 +329,93 @@ async def test_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + client.connect.side_effect = side_effect() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_successful(hass: HomeAssistant, client) -> None: + """Test that the reconfigure is successful.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "new_host" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WebOsTvPairError, "error_pairing"), + (ConnectionResetError, "cannot_connect"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, client, side_effect, error +) -> None: + """Test reconfigure errors.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + client.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: + """Test abort if reconfigure host is wrong webOS TV device.""" + entry = await setup_webostv(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + client.tv_info.hello = {"deviceUUID": "wrong_uuid"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "new_host"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 41045969335..c14e8f4542a 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -1,4 +1,4 @@ -"""The tests for WebOS TV device triggers.""" +"""The tests for LG webOS TV device triggers.""" import pytest @@ -104,14 +104,14 @@ async def test_if_fires_on_turn_on_request( assert service_calls[2].data["id"] == 0 -async def test_failure_scenarios( +async def test_invalid_trigger_raises( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client ) -> None: - """Test failure scenarios.""" + """Test invalid trigger platform or device id raises.""" await setup_webostv(hass) # Test wrong trigger platform type - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match="Unhandled trigger type: wrong.type"): await device_trigger.async_attach_trigger( hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} ) @@ -128,7 +128,26 @@ async def test_failure_scenarios( }, ) - entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + +@pytest.mark.parametrize( + ("domain", "entry_state"), + [ + (DOMAIN, ConfigEntryState.NOT_LOADED), + ("fake", ConfigEntryState.LOADED), + ], +) +async def test_invalid_entry_raises( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + domain: str, + entry_state: ConfigEntryState, +) -> None: + """Test device id not loaded or from another domain raises.""" + await setup_webostv(hass) + + entry = MockConfigEntry(domain=domain, state=entry_state, data={}) + entry.runtime_data = None entry.add_to_hass(hass) device = device_registry.async_get_or_create( diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 3d7cb00e021..0cf815ce9e2 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -1,6 +1,8 @@ -"""Tests for the diagnostics data provided by LG webOS Smart TV.""" +"""Tests for the diagnostics data provided by LG webOS TV.""" + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from . import setup_webostv @@ -10,56 +12,13 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, client + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + client, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" entry = await setup_webostv(hass) - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == { - "client": { - "is_registered": True, - "is_connected": True, - "current_app_id": "com.webos.app.livetv", - "current_channel": { - "channelId": "ch1id", - "channelName": "Channel 1", - "channelNumber": "1", - }, - "apps": { - "com.webos.app.livetv": { - "icon": REDACTED, - "id": "com.webos.app.livetv", - "largeIcon": REDACTED, - "title": "Live TV", - } - }, - "inputs": { - "in1": {"appId": "app0", "id": "in1", "label": "Input01"}, - "in2": {"appId": "app1", "id": "in2", "label": "Input02"}, - }, - "system_info": {"modelName": "TVFAKE"}, - "software_info": {"major_ver": "major", "minor_ver": "minor"}, - "hello_info": {"deviceUUID": "**REDACTED**"}, - "sound_output": "speaker", - "is_on": True, - }, - "entry": { - "entry_id": entry.entry_id, - "version": 1, - "minor_version": 1, - "domain": "webostv", - "title": "fake_webos", - "data": { - "client_secret": "**REDACTED**", - "host": "**REDACTED**", - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - "created_at": entry.created_at.isoformat(), - "modified_at": entry.modified_at.isoformat(), - "discovery_keys": {}, - }, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index e2638c86f5e..cd8f443c8fd 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -1,24 +1,21 @@ """The tests for the LG webOS TV platform.""" -from unittest.mock import Mock - from aiowebostv import WebOsTvPairError -import pytest -from homeassistant.components.webostv.const import DOMAIN +from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST +from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_SECRET, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import setup_webostv +from .const import ENTITY_ID -async def test_reauth_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_reauth_setup_entry(hass: HomeAssistant, client) -> None: """Test reauth flow triggered by setup entry.""" - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -35,12 +32,46 @@ async def test_reauth_setup_entry( assert flow["context"].get("entry_id") == entry.entry_id -async def test_key_update_setup_entry( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_key_update_setup_entry(hass: HomeAssistant, client) -> None: """Test key update from setup entry.""" - monkeypatch.setattr(client, "client_key", "new_key") + client.client_key = "new_key" entry = await setup_webostv(hass) assert entry.state is ConfigEntryState.LOADED assert entry.data[CONF_CLIENT_SECRET] == "new_key" + + +async def test_update_options(hass: HomeAssistant, client) -> None: + """Test update options triggers reload.""" + config_entry = await setup_webostv(hass) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.update_listeners is not None + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input01", "Input02", "Live TV"] + + # remove Input01 and reload + new_options = config_entry.options.copy() + new_options[CONF_SOURCES] = ["Input02", "Live TV"] + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + assert sources == ["Input02", "Live TV"] + + +async def test_disconnect_on_stop(hass: HomeAssistant, client) -> None: + """Test we disconnect the client and clear callbacks when Home Assistants stops.""" + config_entry = await setup_webostv(hass) + + assert client.disconnect.call_count == 0 + assert client.clear_state_update_callbacks.call_count == 0 + assert config_entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert client.disconnect.call_count == 1 + assert client.clear_state_update_callbacks.call_count == 1 + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index e4c02e680bd..59e3fc68cf7 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -1,11 +1,13 @@ -"""The tests for the LG webOS media player platform.""" +"""The tests for the LG webOS TV media player platform.""" from datetime import timedelta from http import HTTPStatus -from unittest.mock import Mock from aiowebostv import WebOsTvPairError +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components import automation from homeassistant.components.media_player import ( @@ -19,7 +21,6 @@ from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaPlayerState, MediaType, @@ -42,9 +43,9 @@ from homeassistant.components.webostv.media_player import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_COMMAND, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + CONF_CLIENT_SECRET, ENTITY_MATCH_NONE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -58,13 +59,11 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_OFF, - STATE_ON, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import setup_webostv from .const import CHANNEL_2, ENTITY_ID, TV_NAME @@ -144,7 +143,7 @@ async def test_media_play_pause(hass: HomeAssistant, client) -> None: ], ) async def test_media_next_previous_track( - hass: HomeAssistant, client, service, client_call, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, service, client_call ) -> None: """Test media next/previous track services.""" await setup_webostv(hass) @@ -157,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - monkeypatch.setattr(client, "current_app_id", "in1") + client.tv_state.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -166,7 +165,7 @@ async def test_media_next_previous_track( async def test_select_source_with_empty_source_list( - hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, client ) -> None: """Ensure we don't call client methods when we don't have sources.""" await setup_webostv(hass) @@ -176,11 +175,14 @@ async def test_select_source_with_empty_source_list( ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent", } - await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + with pytest.raises( + HomeAssistantError, + match=f"Source nonexistent not found in the sources list for {TV_NAME}", + ): + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_not_called() client.set_input.assert_not_called() - assert f"Source nonexistent not found for {TV_NAME}" in caplog.text async def test_select_app_source(hass: HomeAssistant, client) -> None: @@ -227,17 +229,30 @@ async def test_button(hass: HomeAssistant, client) -> None: client.button.assert_called_with("test") -async def test_command(hass: HomeAssistant, client) -> None: +async def test_command( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test generic command functionality.""" await setup_webostv(hass) + client.request.return_value = { + "returnValue": True, + "scenario": "mastervolume_tv_speaker_ext", + "volume": 1, + "muted": False, + } data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_COMMAND: "test", + ATTR_COMMAND: "audio/getVolume", } - await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) + response = await hass.services.async_call( + DOMAIN, SERVICE_COMMAND, data, True, return_response=True + ) await hass.async_block_till_done() - client.request.assert_called_with("test", payload=None) + client.request.assert_called_with("audio/getVolume", payload=None) + assert response == snapshot async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: @@ -256,28 +271,40 @@ async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: ) -async def test_select_sound_output(hass: HomeAssistant, client) -> None: +async def test_select_sound_output( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test select sound output service.""" await setup_webostv(hass) + client.change_sound_output.return_value = { + "returnValue": True, + "method": "setSystemSettings", + } data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_OUTPUT: "external_speaker", } - await hass.services.async_call(DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True) + response = await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_SOUND_OUTPUT, + data, + True, + return_response=True, + ) await hass.async_block_till_done() client.change_sound_output.assert_called_once_with("external_speaker") + assert response == snapshot async def test_device_info_startup_off( - hass: HomeAssistant, - client, - monkeypatch: pytest.MonkeyPatch, - device_registry: dr.DeviceRegistry, + hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - monkeypatch.setattr(client, "system_info", None) - monkeypatch.setattr(client, "is_on", False) + client.tv_info.system = {} + client.tv_state.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -296,8 +323,8 @@ async def test_device_info_startup_off( async def test_entity_attributes( hass: HomeAssistant, client, - monkeypatch: pytest.MonkeyPatch, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test entity attributes.""" entry = await setup_webostv(hass) @@ -305,28 +332,17 @@ async def test_entity_attributes( # Attributes when device is on state = hass.states.get(ENTITY_ID) - attrs = state.attributes - - assert state.state == STATE_ON - assert state.name == TV_NAME - assert attrs[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - assert attrs[ATTR_MEDIA_VOLUME_MUTED] is False - assert attrs[ATTR_MEDIA_VOLUME_LEVEL] == 0.37 - assert attrs[ATTR_INPUT_SOURCE] == "Live TV" - assert attrs[ATTR_INPUT_SOURCE_LIST] == ["Input01", "Input02", "Live TV"] - assert attrs[ATTR_MEDIA_CONTENT_TYPE] == MediaType.CHANNEL - assert attrs[ATTR_MEDIA_TITLE] == "Channel 1" - assert attrs[ATTR_SOUND_OUTPUT] == "speaker" + assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - monkeypatch.setattr(client, "volume", None) + client.tv_state.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - monkeypatch.setattr(client, "current_channel", CHANNEL_2) + client.tv_state.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -334,17 +350,11 @@ async def test_entity_attributes( # Device Info device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) - - assert device - assert device.identifiers == {(DOMAIN, entry.unique_id)} - assert device.manufacturer == "LG" - assert device.name == TV_NAME - assert device.sw_version == "major.minor" - assert device.model == "TVFAKE" + assert device == snapshot # Sound output when off - monkeypatch.setattr(client, "sound_output", None) - monkeypatch.setattr(client, "is_on", False) + client.tv_state.sound_output = None + client.tv_state.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -388,9 +398,7 @@ async def test_play_media(hass: HomeAssistant, client, media_id, ch_id) -> None: client.set_channel.assert_called_once_with(ch_id) -async def test_update_sources_live_tv_find( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: """Test finding live TV app id in update sources.""" await setup_webostv(hass) await client.mock_state_update() @@ -402,14 +410,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is current app - apps = { + client.tv_state.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - monkeypatch.setattr(client, "apps", apps) - monkeypatch.setattr(client, "current_app_id", "some_id") + client.tv_state.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -417,14 +424,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 3 # Live TV is is in inputs - inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": LIVE_TV_APP_ID, }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -432,14 +438,13 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV is current input - inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", "appId": "some_id", }, } - monkeypatch.setattr(client, "inputs", inputs) await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -447,7 +452,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found - monkeypatch.setattr(client, "current_app_id", "other_id") + client.tv_state.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -455,8 +460,8 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Live TV not found in sources/apps but is current app - monkeypatch.setattr(client, "apps", {}) - monkeypatch.setattr(client, "current_app_id", LIVE_TV_APP_ID) + client.tv_state.apps = {} + client.tv_state.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -464,7 +469,7 @@ async def test_update_sources_live_tv_find( assert len(sources) == 1 # Bad update, keep old update - monkeypatch.setattr(client, "inputs", {}) + client.tv_state.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -473,56 +478,95 @@ async def test_update_sources_live_tv_find( async def test_client_disconnected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test error not raised when client is disconnected.""" - await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=TimeoutError)) - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) - await hass.async_block_till_done() - - -async def test_control_error_handling( hass: HomeAssistant, client, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, + freezer: FrozenDateTimeFactory, +) -> None: + """Test error not raised when client is disconnected.""" + await setup_webostv(hass) + client.is_connected.return_value = False + client.connect.side_effect = TimeoutError + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "TimeoutError" not in caplog.text + + +async def test_client_key_update_on_connect( + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory +) -> None: + """Test client key update upon connect.""" + config_entry = await setup_webostv(hass) + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + client.is_connected.return_value = False + client.client_key = "new_key" + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.data[CONF_CLIENT_SECRET] == client.client_key + + +@pytest.mark.parametrize( + ("is_on", "exception", "error_message"), + [ + ( + True, + WebOsTvCommandError("Some error"), + f"Communication error while calling async_media_play for device {TV_NAME}: Some error", + ), + ( + True, + WebOsTvCommandError("Some other error"), + f"Communication error while calling async_media_play for device {TV_NAME}: Some other error", + ), + ( + False, + None, + f"Error calling async_media_play for device {TV_NAME}: Device is off and cannot be controlled", + ), + ], +) +async def test_control_error_handling( + hass: HomeAssistant, + client, + is_on: bool, + exception: Exception, + error_message: str, ) -> None: """Test control errors handling.""" await setup_webostv(hass) - monkeypatch.setattr(client, "play", Mock(side_effect=WebOsTvCommandError)) - data = {ATTR_ENTITY_ID: ENTITY_ID} + client.play.side_effect = exception + client.tv_state.is_on = is_on + await client.mock_state_update() - # Device on, raise HomeAssistantError - with pytest.raises(HomeAssistantError) as exc: + data = {ATTR_ENTITY_ID: ENTITY_ID} + with pytest.raises(HomeAssistantError, match=error_message): await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - assert ( - str(exc.value) - == f"Error calling async_media_play on entity {ENTITY_ID}, state:on" - ) - assert client.play.call_count == 1 + assert client.play.call_count == int(is_on) - # Device off, log a warning - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "play", Mock(side_effect=TimeoutError)) + +async def test_turn_off_when_device_is_off(hass: HomeAssistant, client) -> None: + """Test no error when turning off device that is already off.""" + await setup_webostv(hass) + client.is_on = False await client.mock_state_update() - await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) - assert client.play.call_count == 1 - assert ( - f"Error calling async_media_play on entity {ENTITY_ID}, state:off, error:" - " TimeoutError()" in caplog.text - ) + data = {ATTR_ENTITY_ID: ENTITY_ID} + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, True) + assert client.power_off.call_count == 1 -async def test_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "sound_output", "lineout") + client.tv_state.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -533,7 +577,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -541,7 +585,7 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - monkeypatch.setattr(client, "sound_output", "speaker") + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -577,12 +621,10 @@ async def test_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported -async def test_cached_supported_features( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.tv_state.is_on = False + client.tv_state.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -610,8 +652,8 @@ async def test_cached_supported_features( ) # TV on, support volume mute, step - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "external_speaker") + client.tv_state.is_on = True + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -620,8 +662,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -630,8 +672,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - monkeypatch.setattr(client, "is_on", True) - monkeypatch.setattr(client, "sound_output", "speaker") + client.tv_state.is_on = True + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -642,8 +684,8 @@ async def test_cached_supported_features( assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = ( @@ -684,12 +726,10 @@ async def test_cached_supported_features( ) -async def test_supported_features_no_cache( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - monkeypatch.setattr(client, "is_on", False) - monkeypatch.setattr(client, "sound_output", None) + client.tv_state.is_on = False + client.tv_state.sound_output = None await setup_webostv(hass) supported = ( @@ -729,11 +769,10 @@ async def test_get_image_http( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -755,11 +794,10 @@ async def test_get_image_http_error( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -782,11 +820,10 @@ async def test_get_image_https( client, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - monkeypatch.setitem(client.apps[LIVE_TV_APP_ID], "icon", url) + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -803,16 +840,17 @@ async def test_get_image_https( async def test_reauth_reconnect( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, client, freezer: FrozenDateTimeFactory ) -> None: """Test reauth flow triggered by reconnect.""" entry = await setup_webostv(hass) - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + client.is_connected.return_value = False + client.connect.side_effect = WebOsTvPairError assert entry.state is ConfigEntryState.LOADED - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -829,27 +867,22 @@ async def test_reauth_reconnect( assert flow["context"].get("entry_id") == entry.entry_id -async def test_update_media_state( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - data = {"foregroundAppInfo": [{"playState": "playing"}]} - monkeypatch.setattr(client, "media_state", data) + client.tv_state.media_state = [{"playState": "playing"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - data = {"foregroundAppInfo": [{"playState": "paused"}]} - monkeypatch.setattr(client, "media_state", data) + client.tv_state.media_state = [{"playState": "paused"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - data = {"foregroundAppInfo": [{"playState": "unloaded"}]} - monkeypatch.setattr(client, "media_state", data) + client.tv_state.media_state = [{"playState": "unloaded"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - monkeypatch.setattr(client, "is_on", False) + client.tv_state.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index 75c2e148310..e64d58b8f91 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -1,8 +1,8 @@ -"""The tests for the WebOS TV notify platform.""" +"""The tests for the LG webOS TV notify platform.""" -from unittest.mock import Mock, call +from unittest.mock import call -from aiowebostv import WebOsTvPairError +from aiowebostv import WebOsTvCommandError import pytest from homeassistant.components.notify import ( @@ -13,23 +13,26 @@ from homeassistant.components.notify import ( from homeassistant.components.webostv import DOMAIN from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from homeassistant.util import slugify from . import setup_webostv from .const import TV_NAME ICON_PATH = "/some/path" MESSAGE = "one, two, testing, testing" +SERVICE_NAME = slugify(TV_NAME) async def test_notify(hass: HomeAssistant, client) -> None: """Test sending a message.""" await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + assert hass.services.has_service(NOTIFY_DOMAIN, SERVICE_NAME) await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -44,7 +47,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: MESSAGE, ATTR_DATA: { @@ -59,7 +62,7 @@ async def test_notify(hass: HomeAssistant, client) -> None: await hass.services.async_call( NOTIFY_DOMAIN, - TV_NAME, + SERVICE_NAME, { ATTR_MESSAGE: "only message, no data", }, @@ -72,94 +75,54 @@ async def test_notify(hass: HomeAssistant, client) -> None: ) -async def test_notify_not_connected( - hass: HomeAssistant, client, monkeypatch: pytest.MonkeyPatch -) -> None: - """Test sending a message when client is not connected.""" - await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) - - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - await hass.services.async_call( - NOTIFY_DOMAIN, - TV_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, - }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 2 - client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) - - -async def test_icon_not_found( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - client, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test notify icon not found error.""" - await setup_webostv(hass) - assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) - - monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) - await hass.services.async_call( - NOTIFY_DOMAIN, - TV_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, - }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 - client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) - assert f"Icon {ICON_PATH} not found" in caplog.text - - @pytest.mark.parametrize( - ("side_effect", "error"), + ("is_on", "exception", "error_message"), [ - (WebOsTvPairError, "Pairing with TV failed"), - (ConnectionRefusedError, "TV unreachable"), + ( + True, + WebOsTvCommandError("Some error"), + f"Communication error while sending notification to device {TV_NAME}: Some error", + ), + ( + True, + FileNotFoundError("Some other error"), + f"Icon {ICON_PATH} not found when sending notification for device {TV_NAME}", + ), + ( + False, + None, + f"Error sending notification to device {TV_NAME}: Device is off and cannot be controlled", + ), ], ) -async def test_connection_errors( +async def test_errors( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, client, - monkeypatch: pytest.MonkeyPatch, - side_effect, - error, + is_on: bool, + exception: Exception, + error_message: str, ) -> None: - """Test connection errors scenarios.""" + """Test error scenarios.""" await setup_webostv(hass) - assert hass.services.has_service("notify", TV_NAME) + client.tv_state.is_on = is_on - monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) - monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) - await hass.services.async_call( - NOTIFY_DOMAIN, - TV_NAME, - { - ATTR_MESSAGE: MESSAGE, - ATTR_DATA: { - ATTR_ICON: ICON_PATH, + assert hass.services.has_service("notify", SERVICE_NAME) + + client.send_message.side_effect = exception + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_NAME, + { + ATTR_MESSAGE: MESSAGE, + ATTR_DATA: { + ATTR_ICON: ICON_PATH, + }, }, - }, - blocking=True, - ) - assert client.mock_calls[0] == call.connect() - assert client.connect.call_count == 1 - client.send_message.assert_not_called() - assert error in caplog.text + blocking=True, + ) + + assert client.send_message.call_count == int(is_on) async def test_no_discovery_info( @@ -175,4 +138,4 @@ async def test_no_discovery_info( await hass.async_block_till_done() assert NOTIFY_DOMAIN in hass.config.components assert f"Failed to initialize notification service {DOMAIN}" in caplog.text - assert not hass.services.has_service("notify", TV_NAME) + assert not hass.services.has_service("notify", SERVICE_NAME) diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index d7eeae28ea3..c7decafff73 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -1,4 +1,4 @@ -"""The tests for WebOS TV automation triggers.""" +"""The tests for LG webOS TV automation triggers.""" from unittest.mock import patch @@ -118,10 +118,10 @@ async def test_webostv_turn_on_trigger_entity_id( assert service_calls[1].data["id"] == 0 -async def test_wrong_trigger_platform_type( +async def test_unknown_trigger_platform_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, client ) -> None: - """Test wrong trigger platform type.""" + """Test unknown trigger platform type.""" await setup_webostv(hass) await async_setup_component( @@ -131,7 +131,7 @@ async def test_wrong_trigger_platform_type( automation.DOMAIN: [ { "trigger": { - "platform": "webostv.wrong_type", + "platform": "webostv.unknown", "entity_id": ENTITY_ID, }, "action": { @@ -146,10 +146,7 @@ async def test_wrong_trigger_platform_type( }, ) - assert ( - "ValueError: Unknown webOS Smart TV trigger platform webostv.wrong_type" - in caplog.text - ) + assert "Unknown trigger platform: webostv.unknown" in caplog.text async def test_trigger_invalid_entity_id( @@ -185,7 +182,4 @@ async def test_trigger_invalid_entity_id( }, ) - assert ( - f"ValueError: Entity {invalid_entity} is not a valid webostv entity" - in caplog.text - ) + assert f"Entity {invalid_entity} is not a valid {DOMAIN} entity" in caplog.text diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index d55d2f97017..49ee593fed7 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -3,7 +3,7 @@ from unittest.mock import patch import aiohttp -from aiohttp import WSMsgType +from aiohttp import WSMsgType, web import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider @@ -258,7 +258,7 @@ async def test_auth_sending_binary_disconnects( await ws.send_bytes(b"[INVALID]") auth_msg = await ws.receive() - assert auth_msg.type == WSMsgType.close + assert auth_msg.type is WSMsgType.CLOSE async def test_auth_close_disconnects( @@ -277,7 +277,40 @@ async def test_auth_close_disconnects( await ws.close() auth_msg = await ws.receive() - assert auth_msg.type == WSMsgType.CLOSED + assert auth_msg.type is WSMsgType.CLOSED + + +async def test_auth_error_disconnects( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + ws_response = web.WebSocketResponse() + + with patch( + "homeassistant.components.websocket_api.http.web.WebSocketResponse", + return_value=ws_response, + ): + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + ws_response._reader.feed_data( + aiohttp.WSMessage( + type=WSMsgType.ERROR, data=Exception("explode"), extra=None + ), + 0, + ) + + auth_msg = await ws.receive() + assert auth_msg.type is WSMsgType.CLOSE + + assert "Received error message during auth phase: explode" in caplog.text async def test_auth_sending_unknown_type_disconnects( @@ -296,3 +329,41 @@ async def test_auth_sending_unknown_type_disconnects( await ws._writer.send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close + + +async def test_error_right_after_auth_disconnects( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error right after auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + ws_response = web.WebSocketResponse() + + with patch( + "homeassistant.components.websocket_api.http.web.WebSocketResponse", + return_value=ws_response, + ): + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + ws_response._reader.feed_data( + aiohttp.WSMessage( + type=WSMsgType.ERROR, data=Exception("explode"), extra=None + ), + 0, + ) + + close_error_msg = await ws.receive() + assert close_error_msg.type is WSMsgType.CLOSE + + assert "Received error message during command phase: explode" in caplog.text diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 22e839d84e4..c0114cde42b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -460,10 +460,10 @@ async def test_call_service_child_not_found( "domain_test.test_service which was not found." ) assert msg["error"]["translation_placeholders"] == { - "domain": "non", - "service": "existing", - "child_domain": "domain_test", - "child_service": "test_service", + "domain": "domain_test", + "service": "test_service", + "child_domain": "non", + "child_service": "existing", } assert msg["error"]["translation_key"] == "child_service_not_found" assert msg["error"]["translation_domain"] == "websocket_api" @@ -540,6 +540,7 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: @@ -2390,9 +2391,7 @@ async def test_execute_script( ), ], ) -@pytest.mark.parametrize( - "ignore_translations", ["component.test.exceptions.test_error.message"] -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_execute_script_err_localization( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index 1bbe91fc573..dbdeb0726dd 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -81,7 +81,7 @@ def mock_user_id() -> Generator[AsyncMock]: """Mock the user API call.""" with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as user_mock, ): @@ -93,7 +93,7 @@ def mock_weheat_discover(mock_heat_pump_info) -> Generator[AsyncMock]: """Mock an Weheat discovery.""" with ( patch( - "homeassistant.components.weheat.HeatPumpDiscovery.discover_active", + "homeassistant.components.weheat.HeatPumpDiscovery.async_discover_active", autospec=True, ) as mock_discover, ): diff --git a/tests/components/weheat/snapshots/test_binary_sensor.ambr b/tests/components/weheat/snapshots/test_binary_sensor.ambr index 08d609ca610..bdcd727fbcc 100644 --- a/tests/components/weheat/snapshots/test_binary_sensor.ambr +++ b/tests/components/weheat/snapshots/test_binary_sensor.ambr @@ -1,17 +1,18 @@ # serializer version: 1 -# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-entry] +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump', + 'entity_id': 'binary_sensor.test_model_indoor_unit_auxiliary_water_pump', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Indoor unit auxilary water pump', + 'original_name': 'Indoor unit auxiliary water pump', 'platform': 'weheat', 'previous_unique_id': None, 'supported_features': 0, @@ -32,14 +33,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-state] +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'Test Model Indoor unit auxilary water pump', + 'friendly_name': 'Test Model Indoor unit auxiliary water pump', }), 'context': , - 'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump', + 'entity_id': 'binary_sensor.test_model_indoor_unit_auxiliary_water_pump', 'last_changed': , 'last_reported': , 'last_updated': , @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 1a54711d6c5..77f85224913 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -18,6 +18,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +79,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -132,6 +134,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -232,6 +236,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +289,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -338,6 +344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,6 +399,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +454,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -497,6 +506,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -551,6 +561,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -605,6 +616,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -659,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -713,6 +726,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -764,6 +778,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -818,6 +833,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -872,6 +888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/weheat/test_binary_sensor.py b/tests/components/weheat/test_binary_sensor.py index e75cb282e24..5769fc9a1a8 100644 --- a/tests/components/weheat/test_binary_sensor.py +++ b/tests/components/weheat/test_binary_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -40,7 +39,6 @@ async def test_create_binary_entities( mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test creating entities.""" mock_weheat_discover.return_value = [mock_heat_pump_info] diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py index b33dd0a8db8..45f2285fd03 100644 --- a/tests/components/weheat/test_config_flow.py +++ b/tests/components/weheat/test_config_flow.py @@ -47,7 +47,7 @@ async def test_full_flow( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ) as mock_weheat, ): @@ -89,7 +89,7 @@ async def test_duplicate_unique_id( with ( patch( - "homeassistant.components.weheat.config_flow.get_user_id_from_token", + "homeassistant.components.weheat.config_flow.async_get_user_id_from_token", return_value=USER_UUID_1, ), ): diff --git a/tests/components/weheat/test_init.py b/tests/components/weheat/test_init.py new file mode 100644 index 00000000000..af5e2b8411b --- /dev/null +++ b/tests/components/weheat/test_init.py @@ -0,0 +1,85 @@ +"""Tests for the weheat initialization.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.components.weheat import UnauthorizedException +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import ClientResponseError + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup.""" + mock_weheat_discover.return_value = [mock_heat_pump_info] + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize( + ("setup_exception", "expected_setup_state"), + [ + (HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.FORBIDDEN, ConfigEntryState.SETUP_ERROR), + (HTTPStatus.GATEWAY_TIMEOUT, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_fail( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, + setup_exception: Exception, + expected_setup_state: ConfigEntryState, +) -> None: + """Test the Weheat setup with invalid token setup.""" + with ( + patch( + "homeassistant.components.weheat.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + Mock(real_url="http://example.com"), None, status=setup_exception + ), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_setup_state + + +@pytest.mark.usefixtures("setup_credentials") +async def test_setup_fail_discover( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Weheat setup with and error from the heat pump discovery.""" + mock_weheat_discover.side_effect = UnauthorizedException() + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index 062b84d0423..f3eec282704 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery @@ -41,7 +40,6 @@ async def test_create_entities( mock_weheat_heat_pump: AsyncMock, mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, has_dhw: bool, nr_of_entities: int, ) -> None: diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 50620b20b8b..c302922fe25 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -39,7 +39,12 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: @pytest.fixture(name="mock_auth_api") def fixture_mock_auth_api(): """Set up Auth fixture.""" - with mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth: + with ( + mock.patch("homeassistant.components.whirlpool.Auth") as mock_auth, + mock.patch( + "homeassistant.components.whirlpool.config_flow.Auth", new=mock_auth + ), + ): mock_auth.return_value.do_auth = AsyncMock() mock_auth.return_value.is_access_token_valid.return_value = True yield mock_auth @@ -48,9 +53,15 @@ def fixture_mock_auth_api(): @pytest.fixture(name="mock_appliances_manager_api") def fixture_mock_appliances_manager_api(): """Set up AppliancesManager fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" - ) as mock_appliances_manager: + with ( + mock.patch( + "homeassistant.components.whirlpool.AppliancesManager" + ) as mock_appliances_manager, + mock.patch( + "homeassistant.components.whirlpool.config_flow.AppliancesManager", + new=mock_appliances_manager, + ), + ): mock_appliances_manager.return_value.fetch_appliances = AsyncMock() mock_appliances_manager.return_value.aircons = [ {"SAID": MOCK_SAID1, "NAME": "TestZone"}, @@ -81,9 +92,15 @@ def fixture_mock_appliances_manager_laundry_api(): @pytest.fixture(name="mock_backend_selector_api") def fixture_mock_backend_selector_api(): """Set up BackendSelector fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.BackendSelector" - ) as mock_backend_selector: + with ( + mock.patch( + "homeassistant.components.whirlpool.BackendSelector" + ) as mock_backend_selector, + mock.patch( + "homeassistant.components.whirlpool.config_flow.BackendSelector", + new=mock_backend_selector, + ), + ): yield mock_backend_selector diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index c60ce17b952..ee8abe04bf1 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -38,6 +38,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 1240e1303e1..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -1,9 +1,10 @@ """Test the Whirlpool Sixth Sense config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import aiohttp -from aiohttp.client_exceptions import ClientConnectionError +import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -19,7 +20,23 @@ CONFIG_INPUT = { } -async def test_form(hass: HomeAssistant, region, brand) -> None: +@pytest.fixture(name="mock_whirlpool_setup_entry") +def fixture_mock_whirlpool_setup_entry(): + """Set up async_setup_entry fixture.""" + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") +async def test_form( + hass: HomeAssistant, + region, + brand, + mock_backend_selector_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -28,33 +45,10 @@ async def test_form(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.BackendSelector" - ) as mock_backend_selector, - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test-username" @@ -64,93 +58,82 @@ async def test_form(hass: HomeAssistant, region, brand) -> None: "region": region[0], "brand": brand[0], } - assert len(mock_setup_entry.mock_calls) == 1 - mock_backend_selector.assert_called_once_with(brand[1], region[1]) + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 + mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) -async def test_form_invalid_auth(hass: HomeAssistant, region, brand) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) + + mock_auth_api.return_value.is_access_token_valid.return_value = False + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (AccountLockedError, "account_locked"), + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_error( + hass: HomeAssistant, + exception: Exception, + expected_error: str, + region, + brand, + mock_auth_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=aiohttp.ClientConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - -async def test_form_auth_timeout(hass: HomeAssistant, region, brand) -> None: - """Test we handle auth timeout error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + mock_auth_api.return_value.do_auth.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=TimeoutError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} - -async def test_form_generic_auth_exception(hass: HomeAssistant, region, brand) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + # Test that it succeeds after the error is cleared + mock_auth_api.return_value.do_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - with patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "region": region[0], + "brand": brand[0], + } + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None: """Test we handle cannot connect error.""" mock_entry = MockConfigEntry( @@ -167,36 +150,23 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT + | { + "region": region[0], + "brand": brand[0], + }, + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" -async def test_no_appliances_flow(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_auth_api") +async def test_no_appliances_flow( + hass: HomeAssistant, region, brand, mock_appliances_manager_api: MagicMock +) -> None: """Test we get an error with no appliances.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -205,27 +175,20 @@ async def test_no_appliances_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with ( - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_appliances"} +@pytest.mark.usefixtures( + "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" +) async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -241,30 +204,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.aircons", - return_value=["test"], - ), - patch( - "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -276,7 +219,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: } -async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> None: +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") +async def test_reauth_flow_invalid_auth( + hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( @@ -290,29 +236,34 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, region, brand) -> Non assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch("homeassistant.components.whirlpool.config_flow.Auth.do_auth"), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + + mock_auth_api.return_value.is_access_token_valid.return_value = False + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_reauth_flow_connnection_error( - hass: HomeAssistant, region, brand +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (AccountLockedError, "account_locked"), + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_auth_error( + hass: HomeAssistant, + exception: Exception, + expected_error: str, + region, + brand, + mock_auth_api: MagicMock, ) -> None: """Test a connection error reauth flow.""" @@ -329,25 +280,10 @@ async def test_reauth_flow_connnection_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.whirlpool.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.do_auth", - side_effect=ClientConnectionError, - ), - patch( - "homeassistant.components.whirlpool.config_flow.Auth.is_access_token_valid", - return_value=False, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() - + mock_auth_api.return_value.do_auth.side_effect = exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": expected_error} diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 937502d4d6c..0d99b0596e3 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -70,10 +74,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -110,10 +118,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -150,10 +162,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -190,10 +206,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 4310bc77ebf..b5b1dde1c3d 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -128,6 +131,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -181,6 +185,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +216,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -260,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +297,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -339,6 +347,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -369,6 +378,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -417,6 +427,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -447,6 +458,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -495,6 +507,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -525,6 +538,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -573,6 +587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -603,6 +618,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -651,6 +667,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -681,6 +698,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -730,6 +748,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index d58cc342745..d290bc347a9 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.whois.const import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py index acaf2aef2a8..8b32d7b1633 100644 --- a/tests/components/wilight/__init__.py +++ b/tests/components/wilight/__init__.py @@ -2,19 +2,19 @@ from pywilight.const import DOMAIN -from homeassistant.components import ssdp -from homeassistant.components.ssdp import ( - ATTR_UPNP_MANUFACTURER, - ATTR_UPNP_MODEL_NAME, - ATTR_UPNP_MODEL_NUMBER, - ATTR_UPNP_SERIAL, -) from homeassistant.components.wilight.config_flow import ( CONF_MODEL_NAME, CONF_SERIAL_NUMBER, ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56" UPNP_MANUFACTURER_NOT_WILIGHT = "Test" CONF_COMPONENTS = "components" -MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_P_B = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -46,7 +46,7 @@ MOCK_SSDP_DISCOVERY_INFO_P_B = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, @@ -58,7 +58,7 @@ MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTURER = ssdp.SsdpServiceInfo( }, ) -MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = ssdp.SsdpServiceInfo( +MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTURER = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location=SSDP_LOCATION, diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index be221cad313..ec711def829 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index cfecfb1e28e..ec9fc1ed3fc 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -120,6 +122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -175,6 +178,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -225,6 +229,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,6 +332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +435,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -479,6 +488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -530,6 +540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -578,6 +589,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -631,6 +643,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,6 +697,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +745,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -778,6 +793,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -825,6 +841,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +892,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -927,6 +945,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -978,6 +997,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1086,6 +1107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1140,6 +1162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1194,6 +1217,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1248,6 +1272,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1302,6 +1327,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1356,6 +1382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1410,6 +1437,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1464,6 +1492,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1518,6 +1547,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1572,6 +1602,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1626,6 +1657,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1679,6 +1711,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1729,6 +1762,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1783,6 +1817,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1834,6 +1869,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1889,6 +1925,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1975,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1989,6 +2027,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2087,6 +2126,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2187,6 +2227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2238,6 +2279,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2288,6 +2330,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2338,6 +2381,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2388,6 +2432,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2438,6 +2483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2493,6 +2539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2547,6 +2594,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2601,6 +2649,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2655,6 +2704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2709,6 +2759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2763,6 +2814,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2815,6 +2867,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2868,6 +2921,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2919,6 +2973,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2970,6 +3025,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3021,6 +3077,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3075,6 +3132,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3125,6 +3183,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3139,8 +3198,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Snoring', 'platform': 'withings', @@ -3148,21 +3210,23 @@ 'supported_features': 0, 'translation_key': 'snoring', 'unique_id': 'withings_12345_sleep_snoring', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_snoring-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'henk Snoring', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1080', + 'state': '18.0', }) # --- # name: test_all_entities[sensor.henk_snoring_episode_count-entry] @@ -3174,6 +3238,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3223,6 +3288,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3278,6 +3344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3328,6 +3395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3378,6 +3446,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3429,6 +3498,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3479,6 +3549,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3530,6 +3601,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3581,6 +3653,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3632,6 +3705,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3684,6 +3758,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3730,6 +3805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3778,6 +3854,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3828,6 +3905,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3878,6 +3956,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3929,6 +4008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3983,6 +4063,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index d0ad5b2659a..b61a54150e4 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_integration from .conftest import CLIENT_ID, USER_ID diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index e07e1f90cb4..d88af39488b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -10,6 +10,7 @@ from aiohttp.hdrs import METH_HEAD from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, + WithingsConnectionError, WithingsUnauthorizedError, ) from freezegun.api import FrozenDateTimeFactory @@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry( assert mock_async_active_subscription.call_count == 4 +async def test_internet_timeout_then_restore( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we can recover from internet disconnects.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch.object(cloud, "async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), + patch( + "homeassistant.components.withings.async_get_config_entry_implementation", + ), + patch( + "homeassistant.components.cloud.async_delete_cloudhook", + ), + patch( + "homeassistant.components.withings.webhook_generate_url", + ), + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert cloud.async_active_subscription(hass) is True + assert cloud.async_is_connected(hass) is True + assert withings.revoke_notification_configurations.call_count == 3 + assert withings.subscribe_notification.call_count == 6 + + await hass.async_block_till_done() + + withings.list_notification_configurations.side_effect = WithingsConnectionError + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.revoke_notification_configurations.call_count == 3 + withings.list_notification_configurations.side_effect = None + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.subscribe_notification.call_count == 12 + + @pytest.mark.parametrize( ("body", "expected_code"), [ diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index c60e080f6d4..ddf4a4f452a 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -6,13 +6,13 @@ import pytest from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError from homeassistant import config_entries -from homeassistant.components import dhcp from homeassistant.components.wiz.config_flow import CONF_DEVICE from homeassistant.components.wiz.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( FAKE_DIMMABLE_BULB, @@ -32,7 +32,7 @@ from . import ( from tests.common import MockConfigEntry -DHCP_DISCOVERY = dhcp.DhcpServiceInfo( +DHCP_DISCOVERY = DhcpServiceInfo( hostname="wiz_abcabc", ip=FAKE_IP, macaddress=FAKE_MAC, diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index 4e6260bc9bd..a22c1a3fb85 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 0fb6cff3d51..a99831d1440 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -28,6 +28,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -119,6 +121,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -149,6 +152,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 2998583f8b3..ca3b0a5dc6e 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -30,6 +30,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -259,6 +261,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -289,6 +292,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -350,6 +354,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +385,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -441,6 +447,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -471,6 +478,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index ee3a72ba872..99358153fe1 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -21,6 +21,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -51,6 +52,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +136,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -186,6 +190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -216,6 +221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -269,6 +275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -299,6 +306,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index a1cf515a24b..15db188af5e 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -6,12 +6,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest from wled import WLEDConnectionError -from homeassistant.components import zeroconf from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -43,7 +43,7 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -87,7 +87,7 @@ async def test_zeroconf_during_onboarding( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -132,7 +132,7 @@ async def test_zeroconf_connection_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -175,7 +175,7 @@ async def test_zeroconf_without_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", @@ -200,7 +200,7 @@ async def test_zeroconf_with_mac_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address("192.168.1.123"), ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 14e8b620983..e2935290f03 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 0456f074d49..53b2f6205cb 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index d13e444645d..d6ccebfb5ea 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index 940d4e31e83..b5dddb368c9 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -17,6 +17,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'raum_0', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index 782dc051c8c..2c628bbc296 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -4,12 +4,12 @@ from unittest.mock import AsyncMock, patch import aiohttp -from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.wmspro.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import setup_config_entry diff --git a/tests/components/workday/snapshots/test_diagnostics.ambr b/tests/components/workday/snapshots/test_diagnostics.ambr index f41b86b7f6d..e7331b911a8 100644 --- a/tests/components/workday/snapshots/test_diagnostics.ambr +++ b/tests/components/workday/snapshots/test_diagnostics.ambr @@ -40,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index adbae5676e6..09b0149a424 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -430,7 +430,7 @@ async def test_bad_date_holiday( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.workday.issues.issue_1.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py index f901f605730..4941462cb14 100644 --- a/tests/components/worldclock/test_sensor.py +++ b/tests/components/worldclock/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.worldclock.const import CONF_TIME_FORMAT, DEFAULT_NAME from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index 9f5ec92a5b6..ff3d4960735 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -5,7 +5,7 @@ import re import requests_mock -import homeassistant.components.wsdot.sensor as wsdot +from homeassistant.components.wsdot import sensor as wsdot from homeassistant.components.wsdot.sensor import ( ATTR_DESCRIPTION, ATTR_TIME_UPDATED, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index bdead0f2028..d288c531407 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -36,10 +36,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -82,10 +86,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -127,10 +135,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Test Satellite', 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Test Satellite', 'type': , 'version': 1, diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 0a314f477b1..30faa2dd441 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -9,10 +9,10 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.wyoming.const import DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index f293f976242..0e4bb3da78c 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable import io +import tempfile from typing import Any from unittest.mock import patch import wave @@ -17,17 +18,18 @@ from wyoming.info import Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite +from wyoming.snd import Played from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection -from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components import assist_pipeline, assist_satellite, wyoming from homeassistant.components.wyoming.assist_satellite import WyomingAssistSatellite from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import intent as intent_helper +from homeassistant.helpers import entity_registry as er, intent as intent_helper from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient @@ -65,7 +67,7 @@ def get_test_wav() -> bytes: wav_file.setnchannels(1) # Single frame - wav_file.writeframes(b"123") + wav_file.writeframes(b"1234") return wav_io.getvalue() @@ -73,10 +75,15 @@ def get_test_wav() -> bytes: class SatelliteAsyncTcpClient(MockAsyncTcpClient): """Satellite AsyncTcpClient.""" - def __init__(self, responses: list[Event]) -> None: + def __init__( + self, responses: list[Event], block_until_inject: bool = False + ) -> None: """Initialize client.""" super().__init__(responses) + self.block_until_inject = block_until_inject + self._responses_ready = asyncio.Event() + self.connect_event = asyncio.Event() self.run_satellite_event = asyncio.Event() self.detect_event = asyncio.Event() @@ -188,6 +195,9 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): async def read_event(self) -> Event | None: """Receive.""" + if self.block_until_inject and (not self.responses): + await self._responses_ready.wait() + event = await super().read_event() # Keep sending audio chunks instead of None @@ -196,6 +206,7 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): def inject_event(self, event: Event) -> None: """Put an event in as the next response.""" self.responses = [event, *self.responses] + self._responses_ready.set() async def test_satellite_pipeline(hass: HomeAssistant) -> None: @@ -416,7 +427,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.tts_audio_chunk.rate == 22050 assert mock_client.tts_audio_chunk.width == 2 assert mock_client.tts_audio_chunk.channels == 1 - assert mock_client.tts_audio_chunk.audio == b"123" + assert mock_client.tts_audio_chunk.audio == b"1234" # Pipeline finished pipeline_event_callback( @@ -1283,3 +1294,85 @@ async def test_timers(hass: HomeAssistant) -> None: timer_finished = mock_client.timer_finished assert timer_finished is not None assert timer_finished.id == timer_started.id + + +async def test_announce( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test announce on satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + def async_process_play_media_url(hass: HomeAssistant, media_id: str) -> str: + # Don't create a URL + return media_id + + with ( + tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as temp_wav_file, + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(responses=[], block_until_inject=True), + ) as mock_client, + patch( + "homeassistant.components.assist_satellite.entity.async_process_play_media_url", + new=async_process_play_media_url, + ), + ): + # Use test WAV data for media + with wave.open(temp_wav_file.name, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(22050 * 2)) # 1 sec + + temp_wav_file.seek(0) + + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + satellite_entry = next( + ( + maybe_entry + for maybe_entry in er.async_entries_for_device( + entity_registry, device.device_id + ) + if maybe_entry.domain == assist_satellite.DOMAIN + ), + None, + ) + assert satellite_entry is not None + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + announce_task = hass.async_create_background_task( + hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + "entity_id": satellite_entry.entity_id, + "media_id": temp_wav_file.name, + }, + blocking=True, + ), + "wyoming_satellite_announce", + ) + + # Wait for audio to come from ffmpeg + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Stop announcement from blocking + mock_client.inject_event(Played().event()) + await announce_task + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 625e6f404ad..e3cc1898ce9 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch import requests from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.xiaomi.device_tracker as xiaomi +from homeassistant.components.xiaomi import device_tracker as xiaomi from homeassistant.components.xiaomi.device_tracker import get_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 141e245815e..eb5cf976cb8 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -7,11 +7,11 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_aqara import config_flow, const from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo ZEROCONF_NAME = "name" ZEROCONF_PROP = "properties" @@ -409,7 +409,7 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -456,7 +456,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -476,7 +476,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index e25ac939a53..3d8a4dab244 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -634,6 +634,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "58:2D:34:35:93:21" +async def test_async_step_user_replace_ignored_entry(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=LYWSDCGQ_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.xiaomi_ble.config_flow.async_discovered_service_info", + return_value=[LYWSDCGQ_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.xiaomi_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "58:2D:34:35:93:21"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Temperature/Humidity Sensor 9321 (LYWSDCGQ)" + assert result2["data"] == {} + assert result2["result"].unique_id == "58:2D:34:35:93:21" + + async def test_async_step_user_short_payload(hass: HomeAssistant) -> None: """Test setup from service info cache with devices found but short payloads.""" with patch( diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 218a382ada5..f415a968f25 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -52,7 +52,7 @@ async def test_event_button_press(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -78,7 +78,7 @@ async def test_event_unlock_outside_the_door(hass: HomeAssistant) -> None: hass, make_advertisement( mac, - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) @@ -104,7 +104,7 @@ async def test_event_successful_fingerprint_match_the_door(hass: HomeAssistant) hass, make_advertisement( mac, - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -153,7 +153,7 @@ async def test_event_dimmer_rotate(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak( hass, make_advertisement( - mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6" + mac, b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6" ), ) @@ -182,7 +182,7 @@ async def test_get_triggers_button( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) @@ -406,7 +406,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01" b"\x08\x12\x05\x00\x00\x00q^\xbe\x90", + b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01\x08\x12\x05\x00\x00\x00q^\xbe\x90", ), ) @@ -442,7 +442,7 @@ async def test_if_fires_on_button_press( hass, make_advertisement( mac, - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), ) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_event.py b/tests/components/xiaomi_ble/test_event.py index 1de5859c35e..7f31fe048aa 100644 --- a/tests/components/xiaomi_ble/test_event.py +++ b/tests/components/xiaomi_ble/test_event.py @@ -23,8 +23,7 @@ from tests.components.bluetooth import ( "54:EF:44:E3:9C:BC", make_advertisement( "54:EF:44:E3:9C:BC", - b'XY\x97\td\xbc\x9c\xe3D\xefT" `' - b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3', ), "5b51a7c91cde6707c9ef18dfda143a58", [ @@ -114,7 +113,7 @@ from tests.components.bluetooth import ( "F8:24:41:C5:98:8B", make_advertisement( "F8:24:41:C5:98:8B", - b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f" b"\x13Q\x00\x00\x00\xd6", + b"X0\xb6\x036\x8b\x98\xc5A$\xf8\x8b\xb8\xf2f\x13Q\x00\x00\x00\xd6", ), "b853075158487ca39a5b5ea9", [ @@ -221,7 +220,7 @@ async def test_xiaomi_fingerprint(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7" b"\x06\x00\x05\xff\xff\xff\xff\x00", + b"PD\x9e\x06B\x91\x8a\xebD\x1f\xd7\x06\x00\x05\xff\xff\xff\xff\x00", ), ) @@ -264,7 +263,7 @@ async def test_xiaomi_lock(hass: HomeAssistant) -> None: hass, make_advertisement( "D7:1F:44:EB:8A:91", - b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t" b" \x02\x00\x01\x80|D/a", + b"PD\x9e\x06C\x91\x8a\xebD\x1f\xd7\x0b\x00\t \x02\x00\x01\x80|D/a", ), ) diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 146526c69a5..92fe53a8fc7 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -9,11 +9,11 @@ from miio import DeviceException import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import TEST_MAC @@ -434,7 +434,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -477,7 +477,7 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -497,7 +497,7 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=None, ip_addresses=[], hostname="mock_hostname", @@ -517,7 +517,7 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", @@ -801,7 +801,7 @@ async def zeroconf_device_success( result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(TEST_HOST), ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index e294cb7c76c..9db0d760efb 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ }), diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index b1a9f6a4d86..00653a9b0c1 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ tuple( diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index 811c845e359..16ec0ffbeb4 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_event.py b/tests/components/yale/test_event.py index 7aeb9d8f12b..ce7f2635eea 100644 --- a/tests/components/yale/test_event.py +++ b/tests/components/yale/test_event.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index f6b96120d0d..1a99cf967ba 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr index fcdb7baca03..daa232ab141 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr index e519a880de9..39b3ef09196 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_button.ambr b/tests/components/yale_smart_alarm/snapshots/test_button.ambr index 951caced170..7d52d1d7206 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_button.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr index 34da7db087a..e7c97b9001b 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_select.ambr b/tests/components/yale_smart_alarm/snapshots/test_select.ambr index 52ec7a99c2c..2899e716ea1 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_select.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +186,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +244,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +302,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr index f631a6fcbfe..17c44bf6ebf 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index c546e754239..1b0df05db2c 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -92,6 +92,58 @@ async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize("slot", [0, 1, 66]) +async def test_user_step_from_ignored(hass: HomeAssistant, slot: int) -> None: + """Test user step replaces an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.yalexs_ble.config_flow.async_discovered_service_info", + return_value=[NOT_YALE_DISCOVERY_INFO, YALE_ACCESS_LOCK_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.yalexs_ble.config_flow.PushLock.validate", + ), + patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: slot, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == YALE_ACCESS_LOCK_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: slot, + } + assert result2["result"].unique_id == YALE_ACCESS_LOCK_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 7629d2401c2..51645dee49e 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -7,12 +7,16 @@ from aiomusiccast import MusicCastConnectionException import pytest from homeassistant import config_entries -from homeassistant.components import ssdp from homeassistant.components.yamaha_musiccast.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) from tests.common import MockConfigEntry @@ -103,7 +107,7 @@ def mock_valid_discovery_information(): with patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[ - ssdp.SsdpServiceInfo( + SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", @@ -265,13 +269,13 @@ async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "123456789", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "123456789", }, ), ) @@ -287,13 +291,13 @@ async def test_ssdp_discovery_successful_add_device( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) @@ -329,13 +333,13 @@ async def test_ssdp_discovery_existing_device_update( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://127.0.0.1/desc.xml", upnp={ - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - ssdp.ATTR_UPNP_SERIAL: "1234567890", + ATTR_UPNP_MODEL_NAME: "MC20", + ATTR_UPNP_SERIAL: "1234567890", }, ), ) diff --git a/tests/components/yandex_transport/test_sensor.py b/tests/components/yandex_transport/test_sensor.py index 13432850b2b..dd8e82278f3 100644 --- a/tests/components/yandex_transport/test_sensor.py +++ b/tests/components/yandex_transport/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components import sensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, load_fixture diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index 77878c2be51..098fc025bf3 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -223,7 +223,7 @@ async def test_service_say_timeout( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 @@ -269,7 +269,7 @@ async def test_service_say_http_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index bdd8cdda312..f534b214b1c 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -9,7 +9,6 @@ from async_upnp_client.utils import CaseInsensitiveDict from yeelight import BulbException, BulbType from yeelight.main import _MODEL_SPECS -from homeassistant.components import zeroconf from homeassistant.components.yeelight import ( CONF_MODE_MUSIC, CONF_NIGHTLIGHT_SWITCH_TYPE, @@ -21,6 +20,7 @@ from homeassistant.components.yeelight import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import callback +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo FAIL_TO_BIND_IP = "1.2.3.4" @@ -43,7 +43,7 @@ CAPABILITIES = { ID_DECIMAL = f"{int(ID, 16):08d}" -ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], port=54321, diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 1acb553af3d..a3f83cc03aa 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant import config_entries -from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.components.yeelight.config_flow import ( MODEL_UNKNOWN, CannotConnect, @@ -30,6 +29,12 @@ from homeassistant.components.yeelight.const import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from . import ( CAPABILITIES, @@ -57,7 +62,7 @@ DEFAULT_CONFIG = { CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH, } -SSDP_INFO = ssdp.SsdpServiceInfo( +SSDP_INFO = SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", upnp={}, @@ -493,13 +498,13 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=zeroconf.ZeroconfServiceInfo( + data=ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ) @@ -525,7 +530,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ) @@ -543,7 +548,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip=IP_ADDRESS, macaddress="000000000000", hostname="mock_hostname" ), ) @@ -560,7 +565,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( ip="1.2.3.5", macaddress="000000000001", hostname="mock_hostname" ), ) @@ -574,19 +579,19 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -648,19 +653,19 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), @@ -894,19 +899,19 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: [ ( config_entries.SOURCE_DHCP, - dhcp.DhcpServiceInfo( + DhcpServiceInfo( ip=IP_ADDRESS, macaddress="aabbccddeeff", hostname="mock_hostname" ), ), ( config_entries.SOURCE_HOMEKIT, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address(IP_ADDRESS), ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + properties={ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, type="mock_type", ), ), diff --git a/tests/components/youless/__init__.py b/tests/components/youless/__init__.py index 8770a7e2dc8..03db24cb7f7 100644 --- a/tests/components/youless/__init__.py +++ b/tests/components/youless/__init__.py @@ -25,6 +25,11 @@ async def init_component(hass: HomeAssistant) -> MockConfigEntry: json=load_json_array_fixture("enologic.json", youless.DOMAIN), headers={"Content-Type": "application/json"}, ) + mock.get( + "http://1.1.1.1/f", + json=load_json_object_fixture("phase.json", youless.DOMAIN), + headers={"Content-Type": "application/json"}, + ) entry = MockConfigEntry( domain=youless.DOMAIN, diff --git a/tests/components/youless/fixtures/device.json b/tests/components/youless/fixtures/device.json index 7d089851923..82d07dba739 100644 --- a/tests/components/youless/fixtures/device.json +++ b/tests/components/youless/fixtures/device.json @@ -1,5 +1,5 @@ { "model": "LS120", - "fw": "1.4.2-EL", + "fw": "1.5.1-EL", "mac": "de2:2d2:3d23" } diff --git a/tests/components/youless/fixtures/phase.json b/tests/components/youless/fixtures/phase.json new file mode 100644 index 00000000000..8a5aa3215ef --- /dev/null +++ b/tests/components/youless/fixtures/phase.json @@ -0,0 +1,15 @@ +{ + "tr": 1, + "i1": 0.123, + "v1": 240, + "l1": 462, + "v2": 240, + "l2": 230, + "i2": 0.123, + "v3": 240, + "l3": 230, + "i3": 0.123, + "pp": 1200, + "pts": 2501301621, + "pa": 400 +} diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index bcfd0139e5c..8cb28776d74 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.energy_delivery_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -8,13 +8,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -25,83 +26,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy delivery high', + 'original_name': 'Energy export tariff 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_delivery_high', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_delivery_high-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy delivery high', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_delivery_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_sensors[sensor.energy_delivery_low-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy delivery low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'youless_localhost_delivery_low', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_delivery_low-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy delivery low', + 'friendly_name': 'Energy delivery meter Energy export tariff 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_delivery_low', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.029', }) # --- -# name: test_sensors[sensor.energy_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -110,13 +60,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -127,236 +78,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy high', + 'original_name': 'Energy export tariff 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_high', + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'youless_localhost_delivery_high', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_high-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy high', + 'friendly_name': 'Energy delivery meter Energy export tariff 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4490.631', - }) -# --- -# name: test_sensors[sensor.energy_low-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_low', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_low-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy low', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_low', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4703.562', - }) -# --- -# name: test_sensors[sensor.energy_total-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energy_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9194.164', - }) -# --- -# name: test_sensors[sensor.extra_total-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.extra_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Extra total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Extra total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_total', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_sensors[sensor.extra_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.extra_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Extra usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_usage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Extra usage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_sensors[sensor.gas_usage-entry] +# name: test_sensors[sensor.gas_meter_total_gas_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -365,13 +112,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.gas_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.gas_meter_total_gas_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -381,34 +129,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:fire', - 'original_name': 'Gas usage', + 'original_icon': None, + 'original_name': 'Total gas usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_gas_m3', 'unique_id': 'youless_localhost_gas', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.gas_usage-state] +# name: test_sensors[sensor.gas_meter_total_gas_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', - 'friendly_name': 'Gas usage', - 'icon': 'mdi:fire', + 'friendly_name': 'Gas meter Total gas usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gas_usage', + 'entity_id': 'sensor.gas_meter_total_gas_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1624.264', }) # --- -# name: test_sensors[sensor.phase_1_current-entry] +# name: test_sensors[sensor.power_meter_average_peak-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -417,13 +164,66 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_average_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_peak', + 'unique_id': 'youless_localhost_average_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_average_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Average peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_average_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '400', + }) +# --- +# name: test_sensors[sensor.power_meter_current_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_current_phase_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -434,32 +234,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 1 current', + 'original_name': 'Current phase 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_1_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_1_current-state] +# name: test_sensors[sensor.power_meter_current_phase_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 1 current', + 'friendly_name': 'Power meter Current phase 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_1_current', + 'entity_id': 'sensor.power_meter_current_phase_1', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '0.123', }) # --- -# name: test_sensors[sensor.phase_1_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -468,115 +268,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 1 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_1_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 1 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensors[sensor.phase_2_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_2_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -587,32 +286,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 2 current', + 'original_name': 'Current phase 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_2_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_2_current-state] +# name: test_sensors[sensor.power_meter_current_phase_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 2 current', + 'friendly_name': 'Power meter Current phase 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_2_current', + 'entity_id': 'sensor.power_meter_current_phase_2', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '0.123', }) # --- -# name: test_sensors[sensor.phase_2_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -621,115 +320,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_2_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 2 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_2_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 2 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensors[sensor.phase_3_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_3_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_3', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -740,32 +338,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 current', + 'original_name': 'Current phase 3', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_3_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_3_current-state] +# name: test_sensors[sensor.power_meter_current_phase_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 3 current', + 'friendly_name': 'Power meter Current phase 3', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_3_current', + 'entity_id': 'sensor.power_meter_current_phase_3', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '0.123', }) # --- -# name: test_sensors[sensor.phase_3_power-entry] +# name: test_sensors[sensor.power_meter_current_power_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -774,13 +372,14 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_3_power', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_power_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -791,134 +390,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 power', + 'original_name': 'Current power usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 3 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.phase_3_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 3 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 3 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensors[sensor.power_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.power_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power Usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_power_w', 'unique_id': 'youless_localhost_usage', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.power_usage-state] +# name: test_sensors[sensor.power_meter_current_power_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Power Usage', + 'friendly_name': 'Power meter Current power usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.power_usage', + 'entity_id': 'sensor.power_meter_current_power_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2382', }) # --- -# name: test_sensors[sensor.water_usage-entry] +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -927,13 +424,695 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_low', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4703.562', + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_high', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4490.631', + }) +# --- +# name: test_sensors[sensor.power_meter_month_peak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_month_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Month peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'month_peak', + 'unique_id': 'youless_localhost_month_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_month_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Month peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_month_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_1_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '462', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_2_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_3_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230', + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'youless_localhost_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Power meter Tariff', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'sensor.power_meter_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'youless_localhost_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_total_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9194.164', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_2_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_3_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240', + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_current_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current usage', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_s0_w', + 'unique_id': 'youless_localhost_extra_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'S0 meter Current usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_current_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.s0_meter_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_s0_kwh', + 'unique_id': 'youless_localhost_extra_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'S0 meter Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.water_meter_total_water_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_total_water_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -943,27 +1122,26 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water', - 'original_name': 'Water usage', + 'original_icon': None, + 'original_name': 'Total water usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_water', 'unique_id': 'youless_localhost_water', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.water_usage-state] +# name: test_sensors[sensor.water_meter_total_water_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water usage', - 'icon': 'mdi:water', + 'friendly_name': 'Water meter Total water usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_usage', + 'entity_id': 'sensor.water_meter_total_water_usage', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/youless/test_init.py b/tests/components/youless/test_init.py index 29db8c66af0..9f0956cea35 100644 --- a/tests/components/youless/test_init.py +++ b/tests/components/youless/test_init.py @@ -15,4 +15,4 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert await setup.async_setup_component(hass, youless.DOMAIN, {}) assert entry.state is ConfigEntryState.LOADED - assert len(hass.states.async_entity_ids()) == 19 + assert len(hass.states.async_entity_ids()) == 22 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index be78964f231..56262600511 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -24,9 +24,18 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.generated import zeroconf as zc_gen from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.service_info.zeroconf import ( + ATTR_PROPERTIES_ID, + ZeroconfServiceInfo, +) from homeassistant.setup import ATTR_COMPONENT, async_setup_component -from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.common import ( + MockConfigEntry, + MockModule, + import_and_test_deprecated_constant, + mock_integration, +) NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" @@ -1081,7 +1090,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( @@ -1169,7 +1178,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1203,7 +1212,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1254,7 +1263,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1283,7 +1292,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1301,6 +1310,36 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_async_detect_interfaces_explicitly_before_setup( + hass: HomeAssistant, +) -> None: + """Test interfaces are explicitly set with IPv6 before setup is called.""" + with ( + patch("homeassistant.components.zeroconf.sys.platform", "linux"), + patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, + patch.object(hass.config_entries.flow, "async_init"), + patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch( + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ), + ): + # Call before async_setup has been called + await zeroconf.async_get_async_instance(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_zc.mock_calls[0] == call( + interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef%3"], + ip_version=IPVersion.All, + ) + + async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None: """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" @@ -1655,3 +1694,35 @@ async def test_zeroconf_rediscover_no_match( assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("constant_name", "replacement_name", "replacement"), + [ + ( + "ATTR_PROPERTIES_ID", + "homeassistant.helpers.service_info.zeroconf.ATTR_PROPERTIES_ID", + ATTR_PROPERTIES_ID, + ), + ( + "ZeroconfServiceInfo", + "homeassistant.helpers.service_info.zeroconf.ZeroconfServiceInfo", + ZeroconfServiceInfo, + ), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + zeroconf, + constant_name, + replacement_name, + replacement, + "2026.2", + ) diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index 724414b5965..6cadc025385 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/zeversolar/snapshots/test_sensor.ambr b/tests/components/zeversolar/snapshots/test_sensor.ambr index aaef2c43d79..f948eec79df 100644 --- a/tests/components/zeversolar/snapshots/test_sensor.ambr +++ b/tests/components/zeversolar/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 1dd1e5f81aa..89526f6431e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.zha.helpers import ZHADeviceProxy from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 1b280ea499a..96a61a6628b 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -25,7 +25,7 @@ from zigpy.zcl.clusters.general import Basic, Groups from zigpy.zcl.foundation import Status import zigpy.zdo.types as zdo_t -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -155,6 +155,7 @@ async def zigpy_app_controller(): app.state.node_info.ieee = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") app.state.node_info.manufacturer = "Coordinator Manufacturer" app.state.node_info.model = "Coordinator Model" + app.state.node_info.version = "7.1.4.0 build 389" app.state.network_info.pan_id = 0x1234 app.state.network_info.extended_pan_id = app.state.node_info.ieee app.state.network_info.channel = 15 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index f46a06e84b8..ba8aa9ea245 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -75,7 +75,7 @@ 'manufacturer': 'Coordinator Manufacturer', 'model': 'Coordinator Model', 'nwk': 0, - 'version': None, + 'version': '7.1.4.0 build 389', }), }), 'config': dict({ @@ -113,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 4, @@ -177,16 +179,7 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': list([ - 50, - 79, - 50, - 2, - 0, - 141, - 21, - 0, - ]), + 'value': None, }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index e0229ebe049..94566be2f87 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -20,9 +20,7 @@ from zigpy.exceptions import NetworkNotFormed import zigpy.types from homeassistant import config_entries -from homeassistant.components import ssdp, usb, zeroconf from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( CONF_BAUDRATE, @@ -43,6 +41,13 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER_URL, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -162,7 +167,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-cc2652-poe", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -175,7 +180,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -185,7 +190,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "tubeszb-efr32-poe", "tubeszb-efr32-poe", RadioType.ezsp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-efr32-poe.local.", @@ -198,7 +203,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "network": "ethernet", "board": "esp32-poe", "platform": "ESP32", - "maс": "8c4b14c33c24", + "mac": "8c4b14c33c24", "version": "2023.12.8", }, ), @@ -208,7 +213,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "TubeZB", "tubeszb-cc2652-poe", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -229,7 +234,7 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: "Some Zigbee Gateway (12345)", "aabbccddeeff", RadioType.znp, - zeroconf.ZeroconfServiceInfo( + ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some-zigbee-gateway-12345.local.", @@ -248,7 +253,7 @@ async def test_zeroconf_discovery( entry_name: str, unique_id: str, radio_type: RadioType, - service_info: zeroconf.ZeroconfServiceInfo, + service_info: ZeroconfServiceInfo, hass: HomeAssistant, ) -> None: """Test zeroconf flow -- radio detected.""" @@ -290,7 +295,7 @@ async def test_legacy_zeroconf_discovery_zigate( setup_entry_mock, hass: HomeAssistant ) -> None: """Test zeroconf flow -- zigate radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway.local.", @@ -339,7 +344,7 @@ async def test_legacy_zeroconf_discovery_zigate( async def test_zeroconf_discovery_bad_payload(hass: HomeAssistant) -> None: """Test zeroconf flow with a bad payload.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="some.hostname", @@ -367,7 +372,7 @@ async def test_legacy_zeroconf_discovery_ip_change_ignored(hass: HomeAssistant) ) entry.add_to_hass(hass) - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tubeszb-cc2652-poe.local.", @@ -397,7 +402,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( hass: HomeAssistant, ) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", @@ -429,7 +434,7 @@ async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -475,7 +480,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}", return_value=True) async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None: """Test zigate usb flow -- radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="0403", vid="6015", @@ -528,7 +533,7 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None ) async def test_discovery_via_usb_no_radio(hass: HomeAssistant) -> None: """Test usb flow -- no radio detected.""" - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/null", pid="AAAA", vid="AAAA", @@ -561,7 +566,7 @@ async def test_discovery_via_usb_already_setup(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} ).add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -595,7 +600,7 @@ async def test_discovery_via_usb_path_does_not_change(hass: HomeAssistant) -> No ) entry.add_to_hass(hass) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -622,7 +627,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) """Test usb flow -- deconz discovered.""" result = await hass.config_entries.flow.async_init( "deconz", - data=ssdp.SsdpServiceInfo( + data=SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", ssdp_location="http://1.2.3.4:80/", @@ -634,7 +639,7 @@ async def test_discovery_via_usb_deconz_already_discovered(hass: HomeAssistant) context={"source": SOURCE_SSDP}, ) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -656,7 +661,7 @@ async def test_discovery_via_usb_deconz_already_setup(hass: HomeAssistant) -> No """Test usb flow -- deconz setup.""" MockConfigEntry(domain="deconz", data={}).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -680,7 +685,7 @@ async def test_discovery_via_usb_deconz_ignored(hass: HomeAssistant) -> None: domain="deconz", source=config_entries.SOURCE_IGNORE, data={} ).add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -708,7 +713,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non ) entry.add_to_hass(hass) await hass.async_block_till_done() - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -732,7 +737,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( + service_info = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", @@ -1196,7 +1201,7 @@ async def test_onboarding_auto_formation_new_hardware( """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) - discovery_info = usb.UsbServiceInfo( + discovery_info = UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", vid="AAAA", @@ -1909,9 +1914,18 @@ async def test_options_flow_migration_reset_old_adapter( assert result4["step_id"] == "choose_serial_port" -async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "device", + [ + "/dev/ttyAMA1", # CM4 + "/dev/ttyAMA10", # CM5, erroneously detected by pyserial + ], +) +async def test_config_flow_port_yellow_port_name( + hass: HomeAssistant, device: str +) -> None: """Test config flow serial port name for Yellow Zigbee radio.""" - port = com_port(device="/dev/ttyAMA1") + port = com_port(device=device) port.serial_number = None port.manufacturer = None port.description = None diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index ae96de44f17..8a587966f81 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_entity.py b/tests/components/zha/test_entity.py new file mode 100644 index 00000000000..add98bb96bf --- /dev/null +++ b/tests/components/zha/test_entity.py @@ -0,0 +1,47 @@ +"""Test ZHA entities.""" + +from zigpy.profiles import zha +from zigpy.zcl.clusters import general + +from homeassistant.components.zha.helpers import get_zha_gateway +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +async def test_device_registry_via_device( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test ZHA `via_device` is set correctly.""" + + await setup_zha() + gateway = get_zha_gateway(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + reg_coordinator_device = device_registry.async_get_device( + identifiers={("zha", str(gateway.state.node_info.ieee))} + ) + + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.ieee))} + ) + + assert reg_device.via_device_id == reg_coordinator_device.id diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index f8a809df51e..f52b403869e 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -9,7 +9,7 @@ from zigpy.application import ControllerApplication from zigpy.types.basic import uint16_t from zigpy.zcl.clusters import lighting -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.components.zha.helpers import ( cluster_command_schema_to_vol_schema, convert_to_zcl_values, @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( get_zha_data, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_homeassistant_hardware.py b/tests/components/zha/test_homeassistant_hardware.py new file mode 100644 index 00000000000..72285521182 --- /dev/null +++ b/tests/components/zha/test_homeassistant_hardware.py @@ -0,0 +1,120 @@ +"""Test Home Assistant Hardware platform for ZHA.""" + +from unittest.mock import MagicMock, patch + +import pytest +from zigpy.application import ControllerApplication + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.components.zha.homeassistant_hardware import get_firmware_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_get_firmware_info_normal(hass: HomeAssistant) -> None: + """Test `get_firmware_info`.""" + + zha = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data={ + "device": { + "path": "/dev/ttyUSB1", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, + version=4, + ) + zha.add_to_hass(hass) + zha.mock_state(hass, ConfigEntryState.LOADED) + + # With ZHA running + with patch( + "homeassistant.components.zha.homeassistant_hardware.get_zha_gateway" + ) as mock_get_zha_gateway: + mock_get_zha_gateway.return_value.state.node_info.version = "1.2.3.4" + fw_info_running = get_firmware_info(hass, zha) + + assert fw_info_running == FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="zha", + owners=[OwningIntegration(config_entry_id=zha.entry_id)], + ) + assert await fw_info_running.is_running(hass) is True + + # With ZHA not running + zha.mock_state(hass, ConfigEntryState.NOT_LOADED) + fw_info_not_running = get_firmware_info(hass, zha) + + assert fw_info_not_running == FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[OwningIntegration(config_entry_id=zha.entry_id)], + ) + assert await fw_info_not_running.is_running(hass) is False + + +@pytest.mark.parametrize( + "data", + [ + # Missing data + {}, + # Bad radio type + {"device": {"path": "/dev/ttyUSB1"}, "radio_type": "znp"}, + ], +) +async def test_get_firmware_info_errors( + hass: HomeAssistant, data: dict[str, str | int | None] +) -> None: + """Test `get_firmware_info` with config entry data format errors.""" + zha = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data=data, + version=4, + ) + zha.add_to_hass(hass) + + assert (get_firmware_info(hass, zha)) is None + + +async def test_hardware_firmware_info_provider_notification( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, +) -> None: + """Test that the ZHA gateway provides hardware and firmware information.""" + config_entry.add_to_hass(hass) + + await async_setup_component(hass, "homeassistant_hardware", {}) + + callback = MagicMock() + async_register_firmware_info_callback(hass, "/dev/ttyUSB0", callback) + + await hass.config_entries.async_setup(config_entry.entry_id) + + callback.assert_called_once_with( + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version="7.1.4.0 build 389", + source="zha", + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + ) + ) diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 0a51aaa6dba..59494dd0d09 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -11,12 +11,12 @@ import zigpy.config from zigpy.config import CONF_DEVICE_PATH import zigpy.types -from homeassistant.components.usb import UsbServiceInfo from homeassistant.components.zha import radio_manager from homeassistant.components.zha.const import DOMAIN from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.usb import UsbServiceInfo from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 2d69cf1ff36..88fb9974c1b 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant from .common import send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +ENTITY_ID_NO_PREFIX = "sensor.fakemanufacturer_fakemodel" ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -335,7 +336,7 @@ async def async_test_pi_heating_demand( "humidity", async_test_humidity, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -344,7 +345,7 @@ async def async_test_pi_heating_demand( "temperature", async_test_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -353,7 +354,7 @@ async def async_test_pi_heating_demand( "pressure", async_test_pressure, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -362,7 +363,7 @@ async def async_test_pi_heating_demand( "illuminance", async_test_illuminance, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -492,7 +493,7 @@ async def async_test_pi_heating_demand( "device_temperature", async_test_device_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -501,7 +502,7 @@ async def async_test_pi_heating_demand( "setpoint_change_source", async_test_setpoint_change_source, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -510,7 +511,7 @@ async def async_test_pi_heating_demand( "pi_heating_demand", async_test_pi_heating_demand, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -558,7 +559,6 @@ async def test_sensor( gateway.get_or_create_device(zigpy_device) await gateway.async_device_initialized(zigpy_device) await hass.async_block_till_done(wait_background_tasks=True) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix) zigpy_device = zigpy_device_mock( { @@ -570,6 +570,11 @@ async def test_sensor( } ) + if hass.states.get(ENTITY_ID_NO_PREFIX): + entity_id = ENTITY_ID_NO_PREFIX + else: + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index f9837a7d016..5849cc6f233 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -28,7 +28,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index f6afee9eb83..ae1ea90d1f9 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -420,8 +420,11 @@ async def test_list_groupable_devices( assert entity_reference[ATTR_NAME] is not None assert entity_reference["entity_id"] is not None - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None + if len(endpoint["entities"]) == 1: + assert endpoint["entities"][0]["original_name"] is None + else: + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None # Make sure there are no groupable devices when the device is unavailable # Make device unavailable diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index 19b9733e4f5..880e5c889ec 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 37b1dde7316..bcdc0c3ce16 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -133,9 +133,9 @@ def climate_radio_thermostat_ct100_plus_state_fixture() -> dict[str, Any]: name="climate_radio_thermostat_ct100_plus_different_endpoints_state", scope="package", ) -def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> dict[ + str, Any +]: """Load the thermostat fixture state with values on different endpoints. This device is a radio thermostat ct100. @@ -336,9 +336,9 @@ def lock_id_lock_as_id150_state_fixture() -> dict[str, Any]: @pytest.fixture( name="climate_radio_thermostat_ct101_multiple_temp_units_state", scope="package" ) -def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> ( - dict[str, Any] -): +def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> dict[ + str, Any +]: """Load the climate multiple temp units node state fixture data.""" return load_json_object_fixture( "climate_radio_thermostat_ct101_multiple_temp_units_state.json", DOMAIN diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index a3f70e92dcf..42c5d59d7ad 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4930,6 +4930,9 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED +@pytest.mark.skip( + reason="The test needs to be updated to reflect what happens when resetting the controller" +) async def test_hard_reset_controller( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -5281,6 +5284,20 @@ async def test_subscribe_s2_inclusion( assert msg["success"] assert msg["result"] is None + # Test receiving requested grant event + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": { + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "clientSideAuth": False, + }, + }, + ) + client.driver.receive_event(event) + # Test receiving DSK request event event = Event( type="validate dsk and enter pin", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index b60515cacd4..e7239c23de6 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -16,13 +16,13 @@ from serial.tools.list_ports_common import ListPortInfo from zwave_js_server.version import VersionInfo from homeassistant import config_entries -from homeassistant.components import usb -from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -33,7 +33,7 @@ ADDON_DISCOVERY_INFO = { } -USB_DISCOVERY_INFO = usb.UsbServiceInfo( +USB_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zwave", pid="AAAA", vid="AAAA", @@ -42,7 +42,7 @@ USB_DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="test", ) -NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +NORTEK_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="8A2A", vid="10C4", @@ -51,7 +51,7 @@ NORTEK_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( manufacturer="nortek", ) -CP2652_ZIGBEE_DISCOVERY_INFO = usb.UsbServiceInfo( +CP2652_ZIGBEE_DISCOVERY_INFO = UsbServiceInfo( device="/dev/zigbee", pid="EA60", vid="10C4", diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4f858f3e545..c575066b57c 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -847,7 +847,7 @@ async def test_issue_registry( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (SupervisorError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d237a6e410a..1d0f74c7269 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -10,8 +10,7 @@ from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import device_registry as dr, issue_registry as ir from tests.components.repairs import ( async_process_repairs_platforms, @@ -181,7 +180,7 @@ async def test_device_config_file_changed_ignore_step( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.zwave_js.issues.invalid_issue.title"], ) async def test_invalid_issue( diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index ddfd205b017..d26cccbc7d5 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -10,7 +10,7 @@ from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import replace_value_of_zwave_value diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index a71df8751b6..f784d7db2db 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -4,14 +4,14 @@ from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.zwave_me.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry -MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( +MOCK_ZEROCONF_DATA = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.14"), ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", diff --git a/tests/conftest.py b/tests/conftest.py index 987173a0b5e..2f7330ebf22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ import gc import itertools import logging import os +import pathlib import reprlib from shutil import rmtree import sqlite3 @@ -33,6 +34,7 @@ import bcrypt import freezegun import multidict import pytest +import pytest_asyncio import pytest_socket import requests_mock import respx @@ -43,12 +45,12 @@ from homeassistant import block_async_io from homeassistant.exceptions import ServiceNotFound # Setup patching of recorder functions before any other Home Assistant imports -from . import patch_recorder # noqa: F401, isort:skip +from . import patch_recorder # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip -from homeassistant import core as ha, loader, runner +from homeassistant import components, core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant @@ -84,12 +86,13 @@ from homeassistant.helpers import ( issue_registry as ir, label_registry as lr, recorder as recorder_helper, + translation as translation_helper, ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, location +from homeassistant.util import dt as dt_util, location as location_util from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_handles from homeassistant.util.json import json_loads @@ -101,6 +104,7 @@ from .typing import ( MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient, + RecorderInstanceContextManager, RecorderInstanceGenerator, WebSocketGenerator, ) @@ -116,6 +120,7 @@ from .common import ( # noqa: E402, isort:skip CLIENT_ID, INSTANCES, MockConfigEntry, + MockMqttReasonCode, MockUser, async_fire_mqtt_message, async_test_home_assistant, @@ -249,7 +254,9 @@ def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]): # Guard a few functions that would make network connections -location.async_detect_location_info = check_real(location.async_detect_location_info) +location_util.async_detect_location_info = check_real( + location_util.async_detect_location_info +) @pytest.fixture(name="caplog") @@ -412,7 +419,9 @@ def verify_cleanup( try: # Verify respx.mock has been cleaned up - assert not respx.mock.routes, "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + assert not respx.mock.routes, ( + "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + ) finally: # Clear mock routes not break subsequent tests respx.mock.clear() @@ -580,7 +589,7 @@ async def hass( exceptions.append( Exception( "Received exception handler without exception, " - f"but with message: {context["message"]}" + f"but with message: {context['message']}" ) ) orig_exception_handler(loop, context) @@ -924,7 +933,13 @@ def fail_on_log_exception( @pytest.fixture def mqtt_config_entry_data() -> dict[str, Any] | None: - """Fixture to allow overriding MQTT config.""" + """Fixture to allow overriding MQTT entry data.""" + return None + + +@pytest.fixture +def mqtt_config_entry_options() -> dict[str, Any] | None: + """Fixture to allow overriding MQTT entry options.""" return None @@ -957,17 +972,23 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: def _async_fire_mqtt_message(topic, payload, qos, retain): async_fire_mqtt_message(hass, topic, payload or b"", qos, retain) mid = get_mid() - hass.loop.call_soon(mock_client.on_publish, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None + ) return FakeInfo(mid) def _subscribe(topic, qos=0): mid = get_mid() - hass.loop.call_soon(mock_client.on_subscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_subscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _unsubscribe(topic): mid = get_mid() - hass.loop.call_soon(mock_client.on_unsubscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_unsubscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _connect(*args, **kwargs): @@ -976,7 +997,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: # the behavior. mock_client.reconnect() hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ) mock_client.on_socket_open( mock_client, None, Mock(fileno=Mock(return_value=-1)) @@ -1001,6 +1022,7 @@ async def mqtt_mock( mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1012,6 +1034,7 @@ async def _mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase @@ -1019,17 +1042,19 @@ async def _mqtt_mock_entry( from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel if mqtt_config_entry_data is None: - mqtt_config_entry_data = { - mqtt.CONF_BROKER: "mock-broker", - mqtt.CONF_BIRTH_MESSAGE: {}, - } + mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} + if mqtt_config_entry_options is None: + mqtt_config_entry_options = {mqtt.CONF_BIRTH_MESSAGE: {}} await hass.async_block_till_done() entry = MockConfigEntry( data=mqtt_config_entry_data, + options=mqtt_config_entry_options, domain=mqtt.DOMAIN, title="MQTT", + version=1, + minor_version=2, ) entry.add_to_hass(hass) @@ -1045,12 +1070,11 @@ async def _mqtt_mock_entry( # Assert that MQTT is setup assert real_mqtt_instance is not None, "MQTT was not setup correctly" - mock_mqtt_instance.conf = real_mqtt_instance.conf # For diagnostics mock_mqtt_instance._mqttc = mqtt_client_mock # connected set to True to get a more realistic behavior when subscribing mock_mqtt_instance.connected = True - mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0) + mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, MockMqttReasonCode()) async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True) await hass.async_block_till_done() @@ -1140,6 +1164,7 @@ async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, + mqtt_config_entry_options: dict[str, Any] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1156,7 +1181,7 @@ async def mqtt_mock_entry( return await mqtt_mock_entry(_async_setup_config_entry) async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data + hass, mqtt_client_mock, mqtt_config_entry_data, mqtt_config_entry_options ) as mqtt_mock_entry: yield _setup_mqtt_entry @@ -1164,15 +1189,31 @@ async def mqtt_mock_entry( @pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None]: """Mock network.""" - with patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[ - Mock( - nice_name="eth0", - ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], - index=0, - ) - ], + with ( + patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], + ), + patch( + "homeassistant.components.network.async_get_loaded_adapters", + return_value=[ + { + "auto": True, + "default": True, + "enabled": True, + "index": 0, + "ipv4": [{"address": "10.10.10.10", "network_prefix": 24}], + "ipv6": [], + "name": "eth0", + } + ], + ), ): yield @@ -1195,9 +1236,8 @@ def mock_get_source_ip() -> Generator[_patch]: def translations_once() -> Generator[_patch]: """Only load translations once per session. - Warning: having this as a session fixture can cause issues with tests that - create mock integrations, overriding the real integration translations - with empty ones. Translations should be reset after such tests (see #131628) + Note: To avoid issues with tests that mock integrations, translations for + mocked integrations are cleaned up by the evict_faked_translations fixture. """ cache = _TranslationsCacheData({}, {}) patcher = patch( @@ -1211,6 +1251,30 @@ def translations_once() -> Generator[_patch]: patcher.stop() +@pytest.fixture(autouse=True, scope="module") +def evict_faked_translations(translations_once) -> Generator[_patch]: + """Clear translations for mocked integrations from the cache after each module.""" + real_component_strings = translation_helper._async_get_component_strings + with patch( + "homeassistant.helpers.translation._async_get_component_strings", + wraps=real_component_strings, + ) as mock_component_strings: + yield + cache: _TranslationsCacheData = translations_once.kwargs["return_value"] + component_paths = components.__path__ + + for call in mock_component_strings.mock_calls: + integrations: dict[str, loader.Integration] = call.args[3] + for domain, integration in integrations.items(): + if any( + pathlib.Path(f"{component_path}/{domain}") == integration.file_path + for component_path in component_paths + ): + continue + for loaded_for_lang in cache.loaded.values(): + loaded_for_lang.discard(domain) + + @pytest.fixture def disable_translations_once( translations_once: _patch, @@ -1221,8 +1285,8 @@ def disable_translations_once( translations_once.start() -@pytest.fixture(autouse=True, scope="session") -def mock_zeroconf_resolver() -> Generator[_patch]: +@pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") +async def mock_zeroconf_resolver() -> AsyncGenerator[_patch]: """Mock out the zeroconf resolver.""" patcher = patch( "homeassistant.helpers.aiohttp_client._async_make_resolver", @@ -1493,7 +1557,7 @@ async def _async_init_recorder_component( assert (recorder.DOMAIN in hass.config.components) == expected_setup_result else: # Wait for recorder to connect to the database - await recorder_helper.async_wait_recorder(hass) + await hass.data[recorder_helper.DATA_RECORDER].db_connected _LOGGER.info( "Test recorder successfully started, database location: %s", config[recorder.CONF_DB_URL], @@ -1521,7 +1585,7 @@ async def async_test_recorder( enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, enable_migrate_event_ids: bool, -) -> AsyncGenerator[RecorderInstanceGenerator]: +) -> AsyncGenerator[RecorderInstanceContextManager]: """Yield context manager to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder @@ -1687,7 +1751,7 @@ async def async_test_recorder( @pytest.fixture async def async_setup_recorder_instance( - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> AsyncGenerator[RecorderInstanceGenerator]: """Yield callable to setup recorder instance.""" @@ -1700,7 +1764,7 @@ async def async_setup_recorder_instance( expected_setup_result: bool = True, wait_recorder: bool = True, wait_recorder_setup: bool = True, - ) -> AsyncGenerator[recorder.Recorder]: + ) -> recorder.Recorder: """Set up and return recorder instance.""" return await stack.enter_async_context( @@ -1719,7 +1783,7 @@ async def async_setup_recorder_instance( @pytest.fixture async def recorder_mock( recorder_config: dict[str, Any] | None, - async_test_recorder: RecorderInstanceGenerator, + async_test_recorder: RecorderInstanceContextManager, hass: HomeAssistant, ) -> AsyncGenerator[recorder.Recorder]: """Fixture with in-memory recorder.""" diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 84cbb07bd73..55ff772e08e 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -35,3 +36,40 @@ 'via_device_id': , }) # --- +# name: test_device_info_called.1 + DeviceRegistryEntrySnapshot({ + 'area_id': 'heliport', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://192.168.0.100/config', + 'connections': set({ + tuple( + 'mac', + 'efgh', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': 'test-hw', + 'id': , + 'identifiers': set({ + tuple( + 'hue', + 'efgh', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'test-manuf', + 'model': 'test-model', + 'model_id': None, + 'name': 'test-name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Heliport', + 'sw_version': 'test-sw', + 'via_device_id': , + }) +# --- diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 3fb83ae5781..13cb25bc516 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -21,7 +21,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.aiohttp_client as client +from homeassistant.helpers import aiohttp_client as client from homeassistant.util.color import RGBColor from homeassistant.util.ssl import SSLCipherList diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 74f55c86a6c..c69f039027e 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -7,6 +7,13 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -18,6 +25,27 @@ from homeassistant.util.dt import utcnow from tests.common import ANY, async_capture_events, flush_store +@pytest.fixture +async def mock_temperature_humidity_entity(hass: HomeAssistant) -> None: + """Mock temperature and humidity sensors.""" + hass.states.async_set( + "sensor.mock_temperature", + "20", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set( + "sensor.mock_humidity", + "50", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + + async def test_list_areas(area_registry: ar.AreaRegistry) -> None: """Make sure that we can read areas.""" area_registry.async_create("mock") @@ -31,6 +59,7 @@ async def test_create_area( hass: HomeAssistant, freezer: FrozenDateTimeFactory, area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can create an area.""" update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) @@ -48,6 +77,8 @@ async def test_create_area( picture=None, created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id=None, + humidity_entity_id=None, ) assert len(area_registry.areas) == 1 @@ -67,6 +98,8 @@ async def test_create_area( aliases={"alias_1", "alias_2"}, labels={"label1", "label2"}, picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert area2 == ar.AreaEntry( @@ -79,6 +112,8 @@ async def test_create_area( picture="/image/example.png", created_at=utcnow(), modified_at=utcnow(), + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 2 assert area.created_at != area2.created_at @@ -164,6 +199,7 @@ async def test_update_area( floor_registry: fr.FloorRegistry, label_registry: lr.LabelRegistry, freezer: FrozenDateTimeFactory, + mock_temperature_humidity_entity: None, ) -> None: """Make sure that we can read areas.""" created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") @@ -184,6 +220,8 @@ async def test_update_area( labels={"label1", "label2"}, name="mock1", picture="/image/example.png", + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert updated_area != area @@ -197,6 +235,8 @@ async def test_update_area( picture="/image/example.png", created_at=created_at, modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", ) assert len(area_registry.areas) == 1 @@ -274,6 +314,55 @@ async def test_update_area_with_normalized_name_already_in_use( assert len(area_registry.areas) == 2 +@pytest.mark.parametrize( + ("create_kwargs", "error_message"), + [ + ( + {"temperature_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"temperature_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a temperature sensor", + ), + ( + {"temperature_entity_id": "sensor.random"}, + "Entity sensor.random is not a temperature sensor", + ), + ( + {"humidity_entity_id": "sensor.invalid"}, + "Entity sensor.invalid does not exist", + ), + ( + {"humidity_entity_id": "light.kitchen"}, + "Entity light.kitchen is not a humidity sensor", + ), + ( + {"humidity_entity_id": "sensor.random"}, + "Entity sensor.random is not a humidity sensor", + ), + ], +) +async def test_update_area_entity_validation( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + mock_temperature_humidity_entity: None, + create_kwargs: dict[str, Any], + error_message: str, +) -> None: + """Make sure that we can't update an area with an invalid entity.""" + area = area_registry.async_create("mock") + hass.states.async_set("light.kitchen", "on", {}) + hass.states.async_set("sensor.random", "3", {}) + + with pytest.raises(ValueError) as e_info: + area_registry.async_update(area.id, **create_kwargs) + assert str(e_info.value) == error_message + + assert area.temperature_entity_id is None + assert area.humidity_entity_id is None + + async def test_load_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: """Make sure that we can load/save data correctly.""" area1 = area_registry.async_create("mock1") @@ -298,6 +387,8 @@ async def test_loading_area_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored areas on start.""" + created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00") + modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00") hass_storage[ar.STORAGE_KEY] = { "version": ar.STORAGE_VERSION_MAJOR, "minor_version": ar.STORAGE_VERSION_MINOR, @@ -311,8 +402,10 @@ async def test_loading_area_from_storage( "labels": ["mock-label1", "mock-label2"], "name": "mock", "picture": "blah", - "created_at": utcnow().isoformat(), - "modified_at": utcnow().isoformat(), + "created_at": created_at.isoformat(), + "modified_at": modified_at.isoformat(), + "temperature_entity_id": "sensor.mock_temperature", + "humidity_entity_id": "sensor.mock_humidity", } ] }, @@ -322,6 +415,20 @@ async def test_loading_area_from_storage( registry = ar.async_get(hass) assert len(registry.areas) == 1 + area = registry.areas["12345A"] + assert area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + floor_id="first_floor", + icon="mdi:garage", + id="12345A", + labels={"mock-label1", "mock-label2"}, + name="mock", + picture="blah", + created_at=created_at, + modified_at=modified_at, + temperature_entity_id="sensor.mock_temperature", + humidity_entity_id="sensor.mock_humidity", + ) @pytest.mark.parametrize("load_registries", [False]) @@ -359,6 +466,8 @@ async def test_migration_from_1_1( "picture": None, "created_at": "1970-01-01T00:00:00+00:00", "modified_at": "1970-01-01T00:00:00+00:00", + "temperature_entity_id": None, + "humidity_entity_id": None, } ] }, diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py new file mode 100644 index 00000000000..10ff5cb855f --- /dev/null +++ b/tests/helpers/test_backup.py @@ -0,0 +1,42 @@ +"""The tests for the backup helpers.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import backup as backup_helper +from homeassistant.setup import async_setup_component + + +async def test_async_get_manager(hass: HomeAssistant) -> None: + """Test async_get_manager.""" + backup_helper.async_initialize_backup(hass) + task = asyncio.create_task(backup_helper.async_get_manager(hass)) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + manager = await task + assert manager is hass.data[backup_helper.DATA_MANAGER] + + +async def test_async_get_manager_no_backup(hass: HomeAssistant) -> None: + """Test async_get_manager when the backup integration is not enabled.""" + with pytest.raises(HomeAssistantError, match="Backup integration is not available"): + await backup_helper.async_get_manager(hass) + + +async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> None: + """Test test_async_get_manager when the backup integration can't be set up.""" + backup_helper.async_initialize_backup(hass) + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_setup", + side_effect=Exception("Boom!"), + ): + assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) + with ( + pytest.raises(Exception, match="Boom!"), + ): + await backup_helper.async_get_manager(hass) diff --git a/tests/helpers/test_chat_session.py b/tests/helpers/test_chat_session.py new file mode 100644 index 00000000000..f6c2fe5057d --- /dev/null +++ b/tests/helpers/test_chat_session.py @@ -0,0 +1,96 @@ +"""Test the chat session helper.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session +from homeassistant.util import dt as dt_util, ulid as ulid_util + +from tests.common import async_fire_time_changed + + +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.mark.parametrize( + ("start_id", "given_id"), + [ + (None, "mock-ulid"), + # This ULID is not known as a session + ("01JHXE0952TSJCFJZ869AW6HMD", "mock-ulid"), + ("not-a-ulid", "not-a-ulid"), + ], +) +async def test_conversation_id( + hass: HomeAssistant, + start_id: str | None, + given_id: str, + mock_ulid: Mock, +) -> None: + """Test conversation ID generation.""" + with chat_session.async_get_chat_session(hass, start_id) as session: + assert session.conversation_id == given_id + + +async def test_context_var(hass: HomeAssistant) -> None: + """Test context var.""" + with chat_session.async_get_chat_session(hass) as session: + with chat_session.async_get_chat_session( + hass, session.conversation_id + ) as session2: + assert session is session2 + + with chat_session.async_get_chat_session(hass, None) as session2: + assert session.conversation_id != session2.conversation_id + + with chat_session.async_get_chat_session(hass, "something else") as session2: + assert session.conversation_id != session2.conversation_id + + with chat_session.async_get_chat_session( + hass, ulid_util.ulid_now() + ) as session2: + assert session.conversation_id != session2.conversation_id + + +async def test_cleanup( + hass: HomeAssistant, +) -> None: + """Test cleanup of the chat session.""" + with chat_session.async_get_chat_session(hass) as session: + conversation_id = session.conversation_id + + # Reuse conversation ID to ensure we can chat with same session + with chat_session.async_get_chat_session(hass, conversation_id) as session: + assert session.conversation_id == conversation_id + + # Set the last updated to be older than the timeout + hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = ( + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + ) + + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + + # Should not be cleaned up, but it should have scheduled another cleanup + with chat_session.async_get_chat_session(hass, conversation_id) as session: + assert session.conversation_id == conversation_id + + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1), + ) + + # It should be cleaned up now and we start a new conversation + with chat_session.async_get_chat_session(hass, conversation_id) as session: + assert session.conversation_id != conversation_id diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index de7edf42dc2..fc2df8552e7 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -9,12 +9,12 @@ import voluptuous as vol from homeassistant.config import YAML_CONFIG_FILE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.check_config import ( CheckConfigError, HomeAssistantConfig, async_check_ha_config_file, ) -import homeassistant.helpers.config_validation as cv from homeassistant.requirements import RequirementsNotFound from tests.common import ( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 1ec78b20535..b8c8c8a18c8 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -30,7 +30,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 13e28bb8840..172aa393538 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,6 +1,8 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator +import asyncio +from collections.abc import Callable, Generator +from contextlib import contextmanager from unittest.mock import Mock, PropertyMock, patch import pytest @@ -13,22 +15,44 @@ from homeassistant.helpers import config_entry_flow from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +@contextmanager +def _make_discovery_flow_conf( + has_discovered_devices: Callable[[], asyncio.Future[bool] | bool], +) -> Generator[None]: + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + "test", "Test", has_discovered_devices + ) + yield + + @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: - """Register a handler.""" +def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with an async discovery function.""" handler_conf = {"discovered": False} async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] - with patch.dict(config_entries.HANDLERS): - config_entry_flow.register_discovery_flow( - "test", "Test", has_discovered_devices - ) + with _make_discovery_flow_conf(has_discovered_devices): yield handler_conf +@pytest.fixture +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: + """Register a handler with a async friendly callback function.""" + handler_conf = {"discovered": False} + + def has_discovered_devices(hass: HomeAssistant) -> bool: + """Mock if we have discovered devices.""" + return handler_conf["discovered"] + + with _make_discovery_flow_conf(has_discovered_devices): + yield handler_conf + handler_conf = {"discovered": False} + + @pytest.fixture def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]: """Register a handler.""" @@ -95,6 +119,33 @@ async def test_user_has_confirmation( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_user_has_confirmation_async_discovery_flow( + hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool] +) -> None: + """Test user requires confirmation to setup with an async has_discovered_devices.""" + async_discovery_flow_conf["discovered"] = True + mock_platform(hass, "test.config_flow", None) + + result = await hass.config_entries.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["flow_id"] == result["flow_id"] + assert progress[0]["context"] == { + "confirm_only": True, + "source": config_entries.SOURCE_USER, + "unique_id": "test", + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( "source", [ diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 52def52f3f0..0fc6b582bb5 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -595,6 +595,13 @@ async def test_abort_discovered_existing_entries( assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("additional_components", "expected_redirect_uri"), + [ + ([], "https://example.com/auth/external/callback"), + (["my"], "https://my.home-assistant.io/redirect/oauth"), + ], +) @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, @@ -602,8 +609,12 @@ async def test_full_flow( local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + additional_components: list[str], + expected_redirect_uri: str, ) -> None: """Check full flow.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( hass, TEST_DOMAIN, MockOAuth2Implementation() @@ -625,14 +636,14 @@ async def test_full_flow( hass, { "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", + "redirect_uri": expected_redirect_uri, }, ) assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP assert result["url"] == ( f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" + f"&redirect_uri={expected_redirect_uri}" f"&state={state}&scope=read+write" ) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 6fa758aec6e..b2dd8943e78 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -4,6 +4,7 @@ import asyncio from datetime import timedelta import logging from unittest.mock import AsyncMock, Mock +import weakref import pytest @@ -529,3 +530,37 @@ async def test_background( async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done(wait_background_tasks=False) assert len(calls) == 2 + + +async def test_shutdown_releases_parent_class(hass: HomeAssistant) -> None: + """Test shutdown releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + """ + calls = [] + + class SomeClass: + def run_func(self) -> None: + calls.append(None) + + my_class = SomeClass() + my_class_weak_ref = weakref.ref(my_class) + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=True, + function=my_class.run_func, + ) + + # Debouncer keeps a reference to the function, prevening GC + del my_class + await debouncer.async_call() + await hass.async_block_till_done() + assert len(calls) == 1 + assert my_class_weak_ref() is not None + + # Debouncer shutdown releases the class + debouncer.async_shutdown() + assert my_class_weak_ref() is None diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 4cf7e851af3..a74055c59ec 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -295,7 +295,7 @@ def _get_value( return obj.value if isinstance(obj, DeprecatedConstantEnum): - return obj.enum.value + return obj.enum if isinstance(obj, DeprecatedAlias): return obj.value diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 08b984a0477..29edfb3fea7 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -173,6 +173,109 @@ async def test_multiple_config_entries( assert entry3.primary_config_entry == config_entry_1.entry_id +async def test_multiple_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} + entry_id = entry.id + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} + } + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"} + } + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + @pytest.mark.parametrize("load_registries", [False]) @pytest.mark.usefixtures("freezer") async def test_loading_from_storage( @@ -191,6 +294,7 @@ async def test_loading_from_storage( { "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": created_at, @@ -215,6 +319,7 @@ async def test_loading_from_storage( "deleted_devices": [ { "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "id": "bcdefghijklmn", @@ -233,6 +338,7 @@ async def test_loading_from_storage( assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), id="bcdefghijklmn", @@ -251,6 +357,7 @@ async def test_loading_from_storage( assert entry == dr.DeviceEntry( area_id="12345A", config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, created_at=datetime.fromisoformat(created_at), @@ -285,6 +392,7 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), id="bcdefghijklmn", @@ -384,6 +492,7 @@ async def test_migration_from_1_1( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -407,6 +516,7 @@ async def test_migration_from_1_1( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -431,6 +541,7 @@ async def test_migration_from_1_1( "deleted_devices": [ { "config_entries": ["123456"], + "config_entries_subentries": {"123456": [None]}, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "id": "deletedid", @@ -528,6 +639,7 @@ async def test_migration_from_1_2( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -551,6 +663,7 @@ async def test_migration_from_1_2( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -662,6 +775,7 @@ async def test_migration_fom_1_3( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -685,6 +799,7 @@ async def test_migration_fom_1_3( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -798,6 +913,7 @@ async def test_migration_from_1_4( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -821,6 +937,7 @@ async def test_migration_from_1_4( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -936,6 +1053,7 @@ async def test_migration_from_1_5( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -959,6 +1077,7 @@ async def test_migration_from_1_5( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1076,6 +1195,7 @@ async def test_migration_from_1_6( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1099,6 +1219,7 @@ async def test_migration_from_1_6( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1218,6 +1339,7 @@ async def test_migration_from_1_7( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1241,6 +1363,7 @@ async def test_migration_from_1_7( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1303,6 +1426,10 @@ async def test_removing_config_entries( assert entry.id == entry2.id assert entry.id != entry3.id assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries_subentries == { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + } device_registry.async_clear_config_entry(config_entry_1.entry_id) entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) @@ -1311,6 +1438,7 @@ async def test_removing_config_entries( ) assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} assert entry3_removed is None await hass.async_block_till_done() @@ -1325,6 +1453,7 @@ async def test_removing_config_entries( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, }, } assert update_events[2].data == { @@ -1336,6 +1465,10 @@ async def test_removing_config_entries( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, "primary_config_entry": config_entry_1.entry_id, }, } @@ -1382,6 +1515,10 @@ async def test_deleted_device_removing_config_entries( assert entry.id == entry2.id assert entry.id != entry3.id assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries_subentries == { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + } device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry3.id) @@ -1400,6 +1537,7 @@ async def test_deleted_device_removing_config_entries( "device_id": entry2.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, }, } assert update_events[2].data == { @@ -1418,10 +1556,16 @@ async def test_deleted_device_removing_config_entries( device_registry.async_clear_config_entry(config_entry_1.entry_id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} device_registry.async_clear_config_entry(config_entry_2.entry_id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == set() + assert entry.config_entries_subentries == {} # No event when a deleted device is purged await hass.async_block_till_done() @@ -1454,6 +1598,427 @@ async def test_deleted_device_removing_config_entries( assert entry3.id != entry4.id +async def test_removing_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry4 = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "4567")}, + manufacturer="manufacturer", + model="model", + ) + + assert len(device_registry.devices) == 1 + assert entry.id == entry2.id + assert entry.id == entry3.id + assert entry.id == entry4.id + assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry4.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + device_registry.async_update_device( + entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id=None, + ) + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_2.entry_id: {"mock-subentry-id-2-1"} + } + + hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") + assert device_registry.async_get_device(identifiers={("bridgeid", "0123")}) is None + assert device_registry.async_get_device(identifiers={("bridgeid", "4567")}) is None + + await hass.async_block_till_done() + + assert len(update_events) == 8 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} + }, + }, + } + assert update_events[3].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + None, + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + } + }, + "identifiers": {("bridgeid", "0123")}, + }, + } + assert update_events[4].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: { + None, + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: { + "mock-subentry-id-2-1", + }, + }, + }, + } + assert update_events[5].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: { + "mock-subentry-id-2-1", + }, + }, + }, + } + assert update_events[6].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: { + "mock-subentry-id-2-1", + }, + }, + "primary_config_entry": config_entry_1.entry_id, + }, + } + assert update_events[7].data == { + "action": "remove", + "device_id": entry.id, + } + + +async def test_deleted_device_removing_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry4 = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "4567")}, + manufacturer="manufacturer", + model="model", + ) + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + assert entry.id == entry2.id + assert entry.id == entry3.id + assert entry.id == entry4.id + assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry4.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + await hass.async_block_till_done() + + assert len(update_events) == 5 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} + }, + }, + } + assert update_events[3].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + None, + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + } + }, + "identifiers": {("bridgeid", "0123")}, + }, + } + assert update_events[4].data == { + "action": "remove", + "device_id": entry.id, + } + + device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + assert entry.orphaned_timestamp is None + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + assert entry.orphaned_timestamp is None + + # Remove the same subentry again + device_registry.async_clear_config_subentry( + config_entry_1.entry_id, "mock-subentry-id-1-1" + ) + assert ( + device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) is entry + ) + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_2.entry_id: {"mock-subentry-id-2-1"} + } + assert entry.orphaned_timestamp is None + + hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == set() + assert entry.config_entries_subentries == {} + assert entry.orphaned_timestamp is not None + + # No event when a deleted device is purged + await hass.async_block_till_done() + assert len(update_events) == 5 + + # Re-add, expect to keep the device id + hass.config_entries.async_add_subentry( + config_entry_2, + config_entries.ConfigSubentry( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + restored_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert restored_entry.id == entry.id + + # Remove again, and trigger purge + device_registry.async_remove_device(entry.id) + hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == set() + assert entry.config_entries_subentries == {} + assert entry.orphaned_timestamp is not None + + future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 + + with patch("time.time", return_value=future_time): + device_registry.async_purge_expired_orphaned_devices() + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 0 + + # Re-add, expect to get a new device id after the purge + new_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert new_entry.id != entry.id + + async def test_removing_area_id( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry ) -> None: @@ -1834,6 +2399,7 @@ async def test_update( assert updated_entry == dr.DeviceEntry( area_id="12345A", config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, configuration_url="https://example.com/config", connections={("mac", "65:43:21:fe:dc:ba")}, created_at=created_at, @@ -2090,6 +2656,7 @@ async def test_update_remove_config_entries( "device_id": entry2.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, }, } assert update_events[2].data == { @@ -2100,7 +2667,11 @@ async def test_update_remove_config_entries( "action": "update", "device_id": entry.id, "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id} + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, }, } assert update_events[4].data == { @@ -2112,6 +2683,11 @@ async def test_update_remove_config_entries( config_entry_2.entry_id, config_entry_3.entry_id, }, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + config_entry_3.entry_id: {None}, + }, "primary_config_entry": config_entry_1.entry_id, }, } @@ -2119,7 +2695,11 @@ async def test_update_remove_config_entries( "action": "update", "device_id": entry2.id, "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id} + "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, + "config_entries_subentries": { + config_entry_2.entry_id: {None}, + config_entry_3.entry_id: {None}, + }, }, } assert update_events[6].data == { @@ -2128,6 +2708,282 @@ async def test_update_remove_config_entries( } +async def test_update_remove_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry() + config_entry_3.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry_id = entry.id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1"} + } + + entry = device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_1.entry_id, + add_config_subentry_id="mock-subentry-id-1-2", + ) + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"} + } + + # Try adding the same subentry again + assert ( + device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_1.entry_id, + add_config_subentry_id="mock-subentry-id-1-2", + ) + is entry + ) + + entry = device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + entry = device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_3.entry_id, + add_config_subentry_id=None, + ) + assert entry.config_entries == { + config_entry_1.entry_id, + config_entry_2.entry_id, + config_entry_3.entry_id, + } + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + } + + # Try to add a subentry without specifying entry + with pytest.raises( + HomeAssistantError, + match="Can't add config subentry without specifying config entry", + ): + device_registry.async_update_device(entry_id, add_config_subentry_id="blabla") + + # Try to add an unknown subentry + with pytest.raises( + HomeAssistantError, + match=f"Config entry {config_entry_3.entry_id} has no subentry blabla", + ): + device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_3.entry_id, + add_config_subentry_id="blabla", + ) + + # Try to remove a subentry without specifying entry + with pytest.raises( + HomeAssistantError, + match="Can't remove config subentry without specifying config entry", + ): + device_registry.async_update_device( + entry_id, remove_config_subentry_id="blabla" + ) + + assert len(device_registry.devices) == 1 + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1-1", + ) + assert entry.config_entries == { + config_entry_1.entry_id, + config_entry_2.entry_id, + config_entry_3.entry_id, + } + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + } + + # Try removing the same subentry again + assert ( + device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1-1", + ) + is entry + ) + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1-2", + ) + assert entry.config_entries == {config_entry_2.entry_id, config_entry_3.entry_id} + assert entry.config_entries_subentries == { + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + } + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_2.entry_id, + remove_config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_entries == {config_entry_3.entry_id} + assert entry.config_entries_subentries == { + config_entry_3.entry_id: {None}, + } + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_3.entry_id, + remove_config_subentry_id=None, + ) + assert entry is None + + await hass.async_block_till_done() + + assert len(update_events) == 8 + assert update_events[0].data == { + "action": "create", + "device_id": entry_id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: {"mock-subentry-id-1-1"} + }, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + } + }, + }, + } + assert update_events[3].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + }, + }, + } + assert update_events[4].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + }, + }, + } + assert update_events[5].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": { + config_entry_1.entry_id, + config_entry_2.entry_id, + config_entry_3.entry_id, + }, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + }, + "primary_config_entry": config_entry_1.entry_id, + }, + } + assert update_events[6].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, + "config_entries_subentries": { + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + }, + }, + } + assert update_events[7].data == { + "action": "remove", + "device_id": entry_id, + } + + async def test_update_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -2542,6 +3398,7 @@ async def test_restore_shared_device( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, "identifiers": {("entry_123", "0123")}, }, } @@ -2566,6 +3423,7 @@ async def test_restore_shared_device( "device_id": entry.id, "changes": { "config_entries": {config_entry_2.entry_id}, + "config_entries_subentries": {config_entry_2.entry_id: {None}}, "identifiers": {("entry_234", "2345")}, }, } @@ -2871,6 +3729,7 @@ async def test_loading_invalid_configuration_url_from_storage( { "area_id": None, "config_entries": ["1234"], + "config_entries_subentries": {"1234": [None]}, "configuration_url": "invalid", "connections": [], "created_at": "2024-01-01T00:00:00+00:00", @@ -3378,6 +4237,39 @@ async def test_device_registry_identifiers_collision( assert not device1_refetched.identifiers.isdisjoint(device3_refetched.identifiers) +async def test_device_registry_deleted_device_collision( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test update collisions with deleted devices in the device registry.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + + device1 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, + manufacturer="manufacturer", + model="model", + ) + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(device1.id) + assert len(device_registry.deleted_devices) == 1 + + device2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert len(device_registry.deleted_devices) == 1 + + device_registry.async_update_device( + device2.id, + merge_connections={(dr.CONNECTION_NETWORK_MAC, "EE:EE:EE:EE:EE:EE")}, + ) + assert len(device_registry.deleted_devices) == 0 + + async def test_primary_config_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf441f70fd..6cf0e7c54d2 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -11,7 +11,7 @@ from typing import Any from unittest.mock import MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from propcache import cached_property +from propcache.api import cached_property import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -35,7 +35,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import ( @@ -986,7 +986,7 @@ async def _test_friendly_name( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([ent]) @@ -1314,7 +1314,7 @@ async def test_entity_name_translation_placeholder_errors( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([ent]) @@ -1542,7 +1542,7 @@ async def test_friendly_name_updated( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 940bd3e37fd..20c243d0701 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_component import EntityComponent, async_update from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e80006dff84..41b7271150a 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -11,7 +11,7 @@ import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentryData from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, PERCENTAGE, EntityCategory from homeassistant.core import ( CoreState, @@ -36,9 +36,12 @@ from homeassistant.helpers.entity_component import ( DEFAULT_SCAN_INTERVAL, EntityComponent, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -223,7 +226,7 @@ async def test_set_scan_interval_via_platform(hass: HomeAssistant) -> None: def platform_setup( hass: HomeAssistant, config: ConfigType, - add_entities: entity_platform.AddEntitiesCallback, + add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Test the platform setup.""" @@ -862,13 +865,29 @@ async def test_setup_entry( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" - async_add_entities([MockEntity(name="test1", unique_id="unique")]) + async_add_entities([MockEntity(name="test1", unique_id="unique1")]) + async_add_entities( + [MockEntity(name="test2", unique_id="unique2")], + config_subentry_id="mock-subentry-id-1", + ) platform = MockPlatform(async_setup_entry=async_setup_entry) - config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry = MockConfigEntry( + entry_id="super-mock-id", + subentries_data=( + ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -877,11 +896,16 @@ async def test_setup_entry( await hass.async_block_till_done() full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components - assert len(hass.states.async_entity_ids()) == 1 - assert len(entity_registry.entities) == 1 + assert len(hass.states.async_entity_ids()) == 2 + assert len(entity_registry.entities) == 2 entity_registry_entry = entity_registry.entities["test_domain.test1"] assert entity_registry_entry.config_entry_id == "super-mock-id" + assert entity_registry_entry.config_subentry_id is None + + entity_registry_entry = entity_registry.entities["test_domain.test2"] + assert entity_registry_entry.config_entry_id == "super-mock-id" + assert entity_registry_entry.config_subentry_id == "mock-subentry-id-1" async def test_setup_entry_platform_not_ready( @@ -1137,7 +1161,18 @@ async def test_device_info_called( snapshot: SnapshotAssertion, ) -> None: """Test device info is forwarded correctly.""" - config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry = MockConfigEntry( + entry_id="super-mock-id", + subentries_data=( + ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) config_entry.add_to_hass(hass) via = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -1150,7 +1185,7 @@ async def test_device_info_called( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1176,6 +1211,28 @@ async def test_device_info_called( ), ] ) + async_add_entities( + [ + # Valid device info + MockEntity( + unique_id="efgh", + device_info={ + "identifiers": {("hue", "efgh")}, + "configuration_url": "http://192.168.0.100/config", + "connections": {(dr.CONNECTION_NETWORK_MAC, "efgh")}, + "manufacturer": "test-manuf", + "model": "test-model", + "name": "test-name", + "sw_version": "test-sw", + "hw_version": "test-hw", + "suggested_area": "Heliport", + "entry_type": dr.DeviceEntryType.SERVICE, + "via_device": ("hue", "via-id"), + }, + ), + ], + config_subentry_id="mock-subentry-id-1", + ) platform = MockPlatform(async_setup_entry=async_setup_entry) entity_platform = MockEntityPlatform( @@ -1185,11 +1242,20 @@ async def test_device_info_called( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 2 + assert len(hass.states.async_entity_ids()) == 3 device = device_registry.async_get_device(identifiers={("hue", "1234")}) assert device == snapshot assert device.config_entries == {config_entry.entry_id} + assert device.config_entries_subentries == {config_entry.entry_id: {None}} + assert device.primary_config_entry == config_entry.entry_id + assert device.via_device_id == via.id + device = device_registry.async_get_device(identifiers={("hue", "efgh")}) + assert device == snapshot + assert device.config_entries == {config_entry.entry_id} + assert device.config_entries_subentries == { + config_entry.entry_id: {"mock-subentry-id-1"} + } assert device.primary_config_entry == config_entry.entry_id assert device.via_device_id == via.id @@ -1213,7 +1279,7 @@ async def test_device_info_not_overrides( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1266,7 +1332,7 @@ async def test_device_info_homeassistant_url( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1318,7 +1384,7 @@ async def test_device_info_change_to_no_url( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1390,7 +1456,7 @@ async def test_entity_disabled_by_device( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([entity_disabled]) @@ -1876,7 +1942,7 @@ async def test_setup_entry_with_entities_that_block_forever( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1886,6 +1952,7 @@ async def test_setup_entry_with_entities_that_block_forever( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1924,7 +1991,7 @@ async def test_cancellation_is_not_blocked( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1934,6 +2001,7 @@ async def test_cancellation_is_not_blocked( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -2021,7 +2089,7 @@ async def test_entity_name_influences_entity_id( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -2109,7 +2177,7 @@ async def test_translated_entity_name_influences_entity_id( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -2197,7 +2265,7 @@ async def test_translated_device_class_name_influences_entity_id( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([TranslatedDeviceClassEntity(device_class, has_entity_name)]) @@ -2259,7 +2327,7 @@ async def test_device_name_defaulting_config_entry( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([DeviceNameEntity()]) @@ -2315,7 +2383,7 @@ async def test_device_type_error_checking( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([DeviceNameEntity()]) @@ -2334,3 +2402,41 @@ async def test_device_type_error_checking( assert len(device_registry.devices) == 0 assert len(entity_registry.entities) == number_of_entities assert len(hass.states.async_all()) == number_of_entities + + +async def test_add_entity_unknown_subentry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test adding an entity to an unknown subentry.""" + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities( + [MockEntity(name="test", unique_id="unique")], + config_subentry_id="unknown-subentry", + ) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert not await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + full_name = f"{config_entry.domain}.{entity_platform.domain}" + assert full_name not in hass.config.components + assert len(hass.states.async_entity_ids()) == 0 + assert len(entity_registry.entities) == 0 + + assert ( + "Can't add entities to unknown subentry unknown-subentry " + "of config entry super-mock-id" + ) in caplog.text diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 682f7843453..416f2d5121d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -78,7 +78,19 @@ def test_get_or_create_updates_data( freezer: FrozenDateTimeFactory, ) -> None: """Test that we update data in get_or_create.""" - orig_config_entry = MockConfigEntry(domain="light") + config_subentry_id = "blabla" + orig_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id=config_subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) orig_config_entry.add_to_hass(hass) orig_device_entry = device_registry.async_get_or_create( config_entry_id=orig_config_entry.entry_id, @@ -93,6 +105,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"max": 100}, config_entry=orig_config_entry, + config_subentry_id=config_subentry_id, device_id=orig_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, @@ -114,6 +127,7 @@ def test_get_or_create_updates_data( "hue", capabilities={"max": 100}, config_entry_id=orig_config_entry.entry_id, + config_subentry_id=config_subentry_id, created_at=created, device_class=None, device_id=orig_device_entry.id, @@ -148,6 +162,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"new-max": 150}, config_entry=new_config_entry, + config_subentry_id=None, device_id=new_device_entry.id, disabled_by=er.RegistryEntryDisabler.USER, entity_category=EntityCategory.DIAGNOSTIC, @@ -169,6 +184,7 @@ def test_get_or_create_updates_data( area_id=None, capabilities={"new-max": 150}, config_entry_id=new_config_entry.entry_id, + config_subentry_id=None, created_at=created, device_class=None, device_id=new_device_entry.id, @@ -496,6 +512,7 @@ async def test_load_bad_data( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "device_id": None, @@ -526,6 +543,7 @@ async def test_load_bad_data( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "device_id": None, @@ -554,6 +572,7 @@ async def test_load_bad_data( "deleted_entities": [ { "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "entity_id": "test.test3", "id": "00003", @@ -564,6 +583,7 @@ async def test_load_bad_data( }, { "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "entity_id": "test.test4", "id": "00004", @@ -616,11 +636,13 @@ async def test_updating_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config_1 = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config_1.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_1 ) mock_config_2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config_2.add_to_hass(hass) entry2 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config_2 ) @@ -647,6 +669,7 @@ async def test_removing_config_entry_id( """Test that we update config entry id in registry.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -670,11 +693,14 @@ async def test_removing_config_entry_id( async def test_deleted_entity_removing_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test that we update config entry id in registry on deleted entity.""" mock_config1 = MockConfigEntry(domain="light", entry_id="mock-id-1") mock_config2 = MockConfigEntry(domain="light", entry_id="mock-id-2") + mock_config1.add_to_hass(hass) + mock_config2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config1 @@ -705,6 +731,118 @@ async def test_deleted_entity_removing_config_entry_id( assert entity_registry.deleted_entities[("light", "hue", "1234")] == deleted_entry2 +async def test_removing_config_subentry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that we update config subentry id in registry.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) + mock_config.add_to_hass(hass) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=mock_config, + config_subentry_id="mock-subentry-id-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-1" + hass.config_entries.async_remove_subentry(mock_config, "mock-subentry-id-1") + + assert not entity_registry.entities + + await hass.async_block_till_done() + + assert len(update_events) == 2 + assert update_events[0].data == { + "action": "create", + "entity_id": entry.entity_id, + } + assert update_events[1].data == { + "action": "remove", + "entity_id": entry.entity_id, + } + + +async def test_deleted_entity_removing_config_subentry_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we update config subentry id in registry on deleted entity.""" + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + mock_config.add_to_hass(hass) + + entry1 = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=mock_config, + config_subentry_id="mock-subentry-id-1", + ) + assert entry1.config_subentry_id == "mock-subentry-id-1" + entry2 = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + config_entry=mock_config, + config_subentry_id="mock-subentry-id-2", + ) + assert entry2.config_subentry_id == "mock-subentry-id-2" + entity_registry.async_remove(entry1.entity_id) + entity_registry.async_remove(entry2.entity_id) + + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 + deleted_entry1 = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry1.config_entry_id == "mock-id-1" + assert deleted_entry1.config_subentry_id == "mock-subentry-id-1" + assert deleted_entry1.orphaned_timestamp is None + deleted_entry2 = entity_registry.deleted_entities[("light", "hue", "1234")] + assert deleted_entry2.config_entry_id == "mock-id-1" + assert deleted_entry2.config_subentry_id == "mock-subentry-id-2" + assert deleted_entry2.orphaned_timestamp is None + + hass.config_entries.async_remove_subentry(mock_config, "mock-subentry-id-1") + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 + deleted_entry1 = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry1.config_entry_id is None + assert deleted_entry1.config_subentry_id is None + assert deleted_entry1.orphaned_timestamp is not None + assert entity_registry.deleted_entities[("light", "hue", "1234")] == deleted_entry2 + + async def test_removing_area_id(entity_registry: er.EntityRegistry) -> None: """Make sure we can clear area id.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") @@ -760,6 +898,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) "capabilities": {}, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "device_id": None, "disabled_by": None, @@ -938,6 +1077,7 @@ async def test_migration_1_11( "capabilities": {}, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "device_id": None, "disabled_by": None, @@ -966,6 +1106,7 @@ async def test_migration_1_11( "deleted_entities": [ { "config_entry_id": None, + "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "entity_id": "test.deleted_entity", "id": "23456", @@ -979,9 +1120,12 @@ async def test_migration_1_11( } -async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity's unique_id is updated.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config @@ -1007,10 +1151,12 @@ async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> No async def test_update_entity_unique_id_conflict( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test migration raises when unique_id already in use.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1099,9 +1245,12 @@ async def test_update_entity_entity_id_entity_id( assert entity_registry.async_get(state_entity_id) is None -async def test_update_entity(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1126,9 +1275,12 @@ async def test_update_entity(entity_registry: er.EntityRegistry) -> None: entry = updated_entry -async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None: +async def test_update_entity_options( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) @@ -1181,6 +1333,7 @@ async def test_disabled_by(entity_registry: er.EntityRegistry) -> None: async def test_disabled_by_config_entry_pref( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: """Test config entry preference setting disabled_by.""" @@ -1189,6 +1342,7 @@ async def test_disabled_by_config_entry_pref( entry_id="mock-id-1", pref_disable_new_entities=True, ) + mock_config.add_to_hass(hass) entry = entity_registry.async_get_or_create( "light", "hue", "AAAA", config_entry=mock_config ) @@ -1412,7 +1566,7 @@ async def test_remove_config_entry_from_device_removes_entities_2( config_entry_2.entry_id, } - # Create one entity for each config entry + # Create an entity without config entry entry_1 = entity_registry.async_get_or_create( "light", "hue", @@ -1432,6 +1586,208 @@ async def test_remove_config_entry_from_device_removes_entities_2( assert entity_registry.async_is_registered(entry_1.entity_id) +async def test_remove_config_subentry_from_device_removes_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we remove entities tied to a device when config subentry is removed.""" + config_entry_1 = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry_1.add_to_hass(hass) + + # Create device with three config subentries + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert device_entry.config_entries == {config_entry_1.entry_id} + assert device_entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + } + + # Create one entity entry for each config entry or subentry + entry_1 = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-1", + device_id=device_entry.id, + ) + + entry_2 = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-2", + device_id=device_entry.id, + ) + + entry_3 = entity_registry.async_get_or_create( + "sensor", + "device_tracker", + "6789", + config_entry=config_entry_1, + config_subentry_id=None, + device_id=device_entry.id, + ) + + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + + # Remove the first config subentry from the device, the entity associated with it + # should be removed + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1", + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + + # Remove the second config subentry from the device, the entity associated with it + # should be removed + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id=None, + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) + + # Remove the third config subentry from the device, the entity associated with it + # (and the device itself) should be removed + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-2", + ) + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) + + +async def test_remove_config_subentry_from_device_removes_entities_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we don't remove entities with no config entry when device is modified.""" + config_entry_1 = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry_1.add_to_hass(hass) + + # Create device with three config subentries + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert device_entry.config_entries == {config_entry_1.entry_id} + assert device_entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + } + + # Create an entity without config entry or subentry + entry_1 = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + device_id=device_entry.id, + ) + + assert entity_registry.async_is_registered(entry_1.entity_id) + + # Remove the first config subentry from the device + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id=None, + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert entity_registry.async_is_registered(entry_1.entity_id) + + # Remove the second config subentry from the device + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1", + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert entity_registry.async_is_registered(entry_1.entity_id) + + async def test_update_device_race( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1761,6 +2117,25 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None +async def test_config_entry_does_not_exist(entity_registry: er.EntityRegistry) -> None: + """Test adding an entity linked to an unknown config entry.""" + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + pref_disable_new_entities=True, + ) + with pytest.raises(ValueError): + entity_registry.async_get_or_create( + "light", "hue", "1234", config_entry=mock_config + ) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + entity_registry.async_update_entity( + entity_id, config_entry_id=mock_config.entry_id + ) + + async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None: """Test adding an entity linked to an unknown device.""" with pytest.raises(ValueError): @@ -1843,11 +2218,46 @@ async def test_unique_id_non_string( ) +@pytest.mark.parametrize( + ("create_kwargs", "migrate_kwargs", "new_subentry_id"), + [ + ({}, {}, None), + ({"config_subentry_id": None}, {}, None), + ({}, {"new_config_subentry_id": None}, None), + ({}, {"new_config_subentry_id": "mock-subentry-id-2"}, "mock-subentry-id-2"), + ( + {"config_subentry_id": "mock-subentry-id-1"}, + {"new_config_subentry_id": None}, + None, + ), + ( + {"config_subentry_id": "mock-subentry-id-1"}, + {"new_config_subentry_id": "mock-subentry-id-2"}, + "mock-subentry-id-2", + ), + ], +) def test_migrate_entity_to_new_platform( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_kwargs: dict, + migrate_kwargs: dict, + new_subentry_id: str | None, ) -> None: """Test migrate_entity_to_new_platform.""" - orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + orig_config_entry.add_to_hass(hass) orig_unique_id = "5678" orig_entry = entity_registry.async_get_or_create( @@ -1861,6 +2271,7 @@ def test_migrate_entity_to_new_platform( original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", + **create_kwargs, ) assert entity_registry.async_get("light.light") is orig_entry entity_registry.async_update_entity( @@ -1869,7 +2280,19 @@ def test_migrate_entity_to_new_platform( icon="new_icon", ) - new_config_entry = MockConfigEntry(domain="light") + new_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + new_config_entry.add_to_hass(hass) new_unique_id = "1234" assert entity_registry.async_update_entity_platform( @@ -1877,6 +2300,7 @@ def test_migrate_entity_to_new_platform( "hue2", new_unique_id=new_unique_id, new_config_entry_id=new_config_entry.entry_id, + **migrate_kwargs, ) assert not entity_registry.async_get_entity_id("light", "hue", orig_unique_id) @@ -1884,6 +2308,7 @@ def test_migrate_entity_to_new_platform( assert (new_entry := entity_registry.async_get("light.light")) is not orig_entry assert new_entry.config_entry_id == new_config_entry.entry_id + assert new_entry.config_subentry_id == new_subentry_id assert new_entry.unique_id == new_unique_id assert new_entry.name == "new_name" assert new_entry.icon == "new_icon" @@ -1916,6 +2341,99 @@ def test_migrate_entity_to_new_platform( ) +def test_migrate_entity_to_new_platform_error_handling( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrate_entity_to_new_platform.""" + orig_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + orig_config_entry.add_to_hass(hass) + orig_unique_id = "5678" + + orig_entry = entity_registry.async_get_or_create( + "light", + "hue", + orig_unique_id, + suggested_object_id="light", + config_entry=orig_config_entry, + config_subentry_id="mock-subentry-id-1", + disabled_by=er.RegistryEntryDisabler.USER, + entity_category=EntityCategory.CONFIG, + original_device_class="mock-device-class", + original_icon="initial-original_icon", + original_name="initial-original_name", + ) + assert entity_registry.async_get("light.light") is orig_entry + + new_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + new_config_entry.add_to_hass(hass) + new_unique_id = "1234" + + # Test migrating nonexisting entity + with pytest.raises(KeyError, match="'light.not_a_real_light'"): + entity_registry.async_update_entity_platform( + "light.not_a_real_light", + "hue2", + new_unique_id=new_unique_id, + new_config_entry_id=new_config_entry.entry_id, + ) + + # Test migrate entity without new config entry ID + with pytest.raises( + ValueError, + match="new_config_entry_id required because light.light is already linked to a config entry", + ): + entity_registry.async_update_entity_platform( + "light.light", + "hue3", + ) + + # Test migrate entity without new config subentry ID + with pytest.raises( + ValueError, + match="Can't change config entry without changing subentry", + ): + entity_registry.async_update_entity_platform( + "light.light", + "hue3", + new_config_entry_id=new_config_entry.entry_id, + ) + + # Test entity with a state + hass.states.async_set("light.light", "on") + with pytest.raises( + ValueError, match="Only entities that haven't been loaded can be migrated" + ): + entity_registry.async_update_entity_platform( + "light.light", + "hue2", + new_unique_id=new_unique_id, + new_config_entry_id=new_config_entry.entry_id, + ) + + async def test_restore_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -1923,12 +2441,28 @@ async def test_restore_entity( ) -> None: """Make sure entity registry id is stable and entity_id is reused if possible.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) - config_entry = MockConfigEntry(domain="light") + config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) entry2 = entity_registry.async_get_or_create( - "light", "hue", "5678", config_entry=config_entry + "light", + "hue", + "5678", + config_entry=config_entry, + config_subentry_id="mock-subentry-id-1-1", ) entry1 = entity_registry.async_update_entity( @@ -1952,8 +2486,11 @@ async def test_restore_entity( # entity_id is not restored assert attr.evolve(entry1, entity_id="light.hue_1234") == entry1_restored assert entry2 != entry2_restored - # Config entry is not restored - assert attr.evolve(entry2, config_entry_id=None) == entry2_restored + # Config entry and subentry are not restored + assert ( + attr.evolve(entry2, config_entry_id=None, config_subentry_id=None) + == entry2_restored + ) # Remove two of the entities again, then bump time entity_registry.async_remove(entry1_restored.entity_id) @@ -2018,6 +2555,8 @@ async def test_async_migrate_entry_delete_self( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) @@ -2053,6 +2592,8 @@ async def test_async_migrate_entry_delete_other( """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" ) @@ -2260,3 +2801,132 @@ async def test_async_remove_thread_safety( match="Detected code that calls entity_registry.async_remove from a thread.", ): await hass.async_add_executor_job(entity_registry.async_remove, entry.entity_id) + + +async def test_subentry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test subentry error handling.""" + entry1 = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + entry1.add_to_hass(hass) + entry2 = MockConfigEntry( + domain="light", + entry_id="mock-id-2", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) + entry2.add_to_hass(hass) + + with pytest.raises( + ValueError, match="Config entry mock-id-1 has no subentry bad-subentry-id" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="bad-subentry-id", + ) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="mock-subentry-id-1-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-1-1" + + # Try updating subentry + with pytest.raises( + ValueError, match="Config entry mock-id-1 has no subentry bad-subentry-id" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="bad-subentry-id", + ) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="mock-subentry-id-1-2", + ) + assert entry.config_subentry_id == "mock-subentry-id-1-2" + + with pytest.raises( + ValueError, match="Can't change config entry without changing subentry" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry2, + ) + + with pytest.raises( + ValueError, match="Config entry mock-id-2 has no subentry mock-subentry-id-1-2" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry2, + config_subentry_id="mock-subentry-id-1-2", + ) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry2, + config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-2-1" + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id=None, + ) + assert entry.config_subentry_id is None + + entry = entity_registry.async_update_entity( + entry.entity_id, + config_entry_id=entry2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-2-1" diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a0014587cd0..a8691771580 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -13,8 +13,8 @@ from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest +from homeassistant import core as ha from homeassistant.const import MATCH_ALL -import homeassistant.core as ha from homeassistant.core import ( Event, EventStateChangedData, @@ -52,7 +52,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.template import Template, result_as_boolean from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_fire_time_changed_exact diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index 684778fe1b1..4b9f2fa2bf6 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -7,7 +7,7 @@ import pytest from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import HomeAssistant -import homeassistant.helpers.httpx_client as client +from homeassistant.helpers import httpx_client as client from tests.common import MockModule, extract_stack_to_frame, mock_integration diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index ae8c2ed65d0..bf0df305c35 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -234,7 +234,7 @@ async def test_async_match_targets( # Floor 2 floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"}) - area_bedroom_2 = area_registry.async_get_or_create("bedroom") + area_bedroom_2 = area_registry.async_get_or_create("second floor bedroom") area_bedroom_2 = area_registry.async_update( area_bedroom_2.id, floor_id=floor_2.floor_id ) @@ -269,7 +269,7 @@ async def test_async_match_targets( # Floor 3 floor_3 = floor_registry.async_create("third floor", aliases={"upstairs"}) - area_bedroom_3 = area_registry.async_get_or_create("bedroom") + area_bedroom_3 = area_registry.async_get_or_create("third floor bedroom") area_bedroom_3 = area_registry.async_update( area_bedroom_3.id, floor_id=floor_3.floor_id ) @@ -510,6 +510,37 @@ async def test_async_match_targets( bathroom_light_3.entity_id, } + # Check single target constraint + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, single_target=True), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Only one light on the ground floor + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, single_target=True), + preferences=intent.MatchTargetsPreferences(floor_id=floor_1.floor_id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Only one switch in bedroom + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"switch"}, single_target=True), + preferences=intent.MatchTargetsPreferences(area_id=area_bedroom_2.id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bedroom_switch_2.entity_id + async def test_match_device_area( hass: HomeAssistant, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3787526c433..630ed3f4fa1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1,15 +1,17 @@ """Tests for the llm helpers.""" +from datetime import timedelta from decimal import Decimal from unittest.mock import patch import pytest import voluptuous as vol +from homeassistant.components import calendar from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script.config import ScriptConfig -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, State, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -22,8 +24,9 @@ from homeassistant.helpers import ( selector, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_mock_service @pytest.fixture @@ -39,6 +42,14 @@ def llm_context() -> llm.LLMContext: ) +class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "", [], llm_context) + + async def test_get_api_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -50,11 +61,6 @@ async def test_get_api_no_existing( async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test registering an llm api.""" - class MyAPI(llm.API): - async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: - """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) - api = MyAPI(hass=hass, id="test", name="Test") llm.async_register_api(hass, api) @@ -66,6 +72,59 @@ async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> llm.async_register_api(hass, api) +async def test_unregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_reregister_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test unregistering an llm api then re-registering with the same id.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + + +async def test_unregister_twice( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test unregistering an llm api twice.""" + + unreg = llm.async_register_api(hass, MyAPI(hass=hass, id="test", name="Test")) + assert await llm.async_get_api(hass, "test", llm_context) + unreg() + + # Unregistering twice is a bug that should not happen + with pytest.raises(KeyError): + unreg() + + +async def test_multiple_apis(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test registering multiple APIs.""" + + unreg1 = llm.async_register_api(hass, MyAPI(hass=hass, id="test-1", name="Test 1")) + llm.async_register_api(hass, MyAPI(hass=hass, id="test-2", name="Test 2")) + + # Verify both Apis are registered + assert await llm.async_get_api(hass, "test-1", llm_context) + assert await llm.async_get_api(hass, "test-2", llm_context) + + # Unregister and verify only one is left + unreg1() + + with pytest.raises(HomeAssistantError): + assert await llm.async_get_api(hass, "test-1", llm_context) + + assert await llm.async_get_api(hass, "test-2", llm_context) + + async def test_call_tool_no_existing( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -686,7 +745,7 @@ async def test_script_tool( area = area_registry.async_create("Living room") floor = floor_registry.async_create("2") - assert llm.SCRIPT_PARAMETERS_CACHE not in hass.data + assert llm.ACTION_PARAMETERS_CACHE not in hass.data api = await llm.async_get_api(hass, "assist", llm_context) @@ -710,7 +769,7 @@ async def test_script_tool( } assert tool.parameters.schema == schema - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == { + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { "test_script": ( "This is a test script. Aliases: ['script name', 'script alias']", vol.Schema(schema), @@ -807,7 +866,7 @@ async def test_script_tool( ): await hass.services.async_call("script", "reload", blocking=True) - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == {} + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {} api = await llm.async_get_api(hass, "assist", llm_context) @@ -823,7 +882,7 @@ async def test_script_tool( schema = {vol.Required("beer", description="Number of beers"): cv.string} assert tool.parameters.schema == schema - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == { + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { "test_script": ( "This is a new test script. Aliases: ['script name', 'script alias']", vol.Schema(schema), @@ -1106,3 +1165,105 @@ async def test_selector_serializer( assert selector_serializer(selector.FileSelector({"accept": ".txt"})) == { "type": "string" } + + +async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: + """Test the calendar get events tool.""" + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set( + "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} + ) + async_expose_entity(hass, "conversation", "calendar.test_calendar", True) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + tool = next( + (tool for tool in api.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] + + calls = async_mock_service( + hass, + domain=calendar.DOMAIN, + service=calendar.SERVICE_GET_EVENTS, + schema=calendar.SERVICE_GET_EVENTS_SCHEMA, + response={ + "calendar.test_calendar": { + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + }, + supports_response=SupportsResponse.ONLY, + ) + + tool_input = llm.ToolInput( + tool_name="calendar_get_events", + tool_args={ + "calendar": "Mock Calendar Name", + "range": "today", + }, + ) + now = dt_util.now() + with patch("homeassistant.util.dt.now", return_value=now): + response = await api.async_call_tool(tool_input) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == calendar.DOMAIN + assert call.service == calendar.SERVICE_GET_EVENTS + assert call.data == { + "entity_id": ["calendar.test_calendar"], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day() + timedelta(days=1), + } + + assert response == { + "success": True, + "result": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ], + } + + tool_input.tool_args["range"] = "week" + with patch("homeassistant.util.dt.now", return_value=now): + response = await api.async_call_tool(tool_input) + + assert len(calls) == 2 + call = calls[1] + assert call.data == { + "entity_id": ["calendar.test_calendar"], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), + } diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c438e333ae6..df589a41daa 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -452,6 +452,68 @@ async def test_service_response_data_errors( await script_obj.async_run(context=context) +async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> None: + """Test response variable is still set after scopes end.""" + expected_var = {"data": "value-12345"} + + def mock_service(call: ServiceCall) -> ServiceResponse: + """Mock service call.""" + if call.return_response: + return expected_var + return None + + hass.services.async_register( + "test", "script", mock_service, supports_response=SupportsResponse.OPTIONAL + ) + + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "service step1", + "action": "test.script", + "response_variable": "my_response", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + assert result.variables["my_response"] == expected_var + + expected_trace = { + "0": [{"variables": {"my_response": expected_var}}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {}, + "target": {}, + }, + "running_script": False, + }, + "variables": {"my_response": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: """Test the calling of a service with a data_template with a templated key.""" context = Context() @@ -1706,6 +1768,90 @@ async def test_wait_variables_out(hass: HomeAssistant, mode, action_type) -> Non assert float(remaining) == 0.0 +async def test_wait_in_sequence(hass: HomeAssistant) -> None: + """Test wait variable is still set after sequence ends.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + }, + ] + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert result.variables["wait"] == expected_var + + expected_trace = { + "0": [{"variables": {"wait": expected_var}}], + "0/sequence/0": [{"variables": {"state": "off"}}], + "0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + +async def test_wait_in_parallel(hass: HomeAssistant) -> None: + """Test wait variable is not set after parallel ends.""" + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert "wait" not in result.variables + + expected_trace = { + "0": [{}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_wait_for_trigger_bad( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -4118,6 +4264,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4135,6 +4289,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_if_then", "label_if_else", "label_parallel", + "label_sequence", } # Test we cache results. assert script_obj.referenced_labels is script_obj.referenced_labels @@ -4220,6 +4375,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4236,6 +4399,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_if_then", "floor_if_else", "floor_parallel", + "floor_sequence", } # Test we cache results. assert script_obj.referenced_floors is script_obj.referenced_floors @@ -4321,6 +4485,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_sequence"}, + } + ], + }, ] ), "Test Name", @@ -4337,6 +4509,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_if_then", "area_if_else", "area_parallel", + "area_sequence", # 'area_service_template', # no area extraction from template } # Test we cache results. @@ -4437,6 +4610,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.sequence"}, + } + ], + }, ] ), "Test Name", @@ -4456,6 +4637,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.sequence", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4554,6 +4736,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "sequence-device"}, + } + ], + }, ] ), "Test Name", @@ -4575,6 +4765,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "sequence-device", } # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices diff --git a/tests/helpers/test_script_variables.py b/tests/helpers/test_script_variables.py index 3675c857279..974a91674a7 100644 --- a/tests/helpers/test_script_variables.py +++ b/tests/helpers/test_script_variables.py @@ -5,12 +5,13 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.script_variables import ScriptRunVariables, ScriptVariables async def test_static_vars() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, None) assert rendered is not orig assert rendered == orig @@ -20,31 +21,28 @@ async def test_static_vars_run_args() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, {"hello": "override", "run": "var"}) assert rendered == {"hello": "override", "run": "var"} # Make sure we don't change original vars assert orig == orig_copy -async def test_static_vars_no_default() -> None: +async def test_static_vars_simple() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render(None, None, render_as_defaults=False) - assert rendered is not orig - assert rendered == orig + var = ScriptVariables(orig) + rendered = var.async_simple_render({}) + assert rendered is orig -async def test_static_vars_run_args_no_default() -> None: +async def test_static_vars_run_args_simple() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render( - None, {"hello": "override", "run": "var"}, render_as_defaults=False - ) - assert rendered == {"hello": "world", "run": "var"} + var = ScriptVariables(orig) + rendered = var.async_simple_render({"hello": "override", "run": "var"}) + assert rendered is orig # Make sure we don't change original vars assert orig == orig_copy @@ -78,14 +76,14 @@ async def test_template_vars_run_args(hass: HomeAssistant) -> None: } -async def test_template_vars_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ 1 + 1 }}"}) - rendered = var.async_render(hass, None, render_as_defaults=False) + rendered = var.async_simple_render({}) assert rendered == {"hello": 2} -async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_run_args_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA( { @@ -93,16 +91,13 @@ async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: "something_2": "{{ run_var_ex + 1 }}", } ) - rendered = var.async_render( - hass, + rendered = var.async_simple_render( { "run_var_ex": 5, "something_2": 1, - }, - render_as_defaults=False, + } ) assert rendered == { - "run_var_ex": 5, "something": 6, "something_2": 6, } @@ -113,3 +108,90 @@ async def test_template_vars_error(hass: HomeAssistant) -> None: var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ canont.work }}"}) with pytest.raises(TemplateError): var.async_render(hass, None) + + +async def test_script_vars_exit_top_level() -> None: + """Test exiting top level script run variables.""" + script_vars = ScriptRunVariables.create_top_level() + with pytest.raises(ValueError): + script_vars.exit_scope() + + +async def test_script_vars_delete_var() -> None: + """Test deleting from script run variables.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 2}) + with pytest.raises(TypeError): + del script_vars["x"] + with pytest.raises(TypeError): + script_vars.pop("y") + assert script_vars._full_scope == {"x": 1, "y": 2} + + +async def test_script_vars_scopes() -> None: + """Test script run variables scopes.""" + script_vars = ScriptRunVariables.create_top_level() + script_vars["x"] = 1 + script_vars["y"] = 1 + assert script_vars["x"] == 1 + assert script_vars["y"] == 1 + + script_vars_2 = script_vars.enter_scope() + script_vars_2.define_local("x", 2) + assert script_vars_2["x"] == 2 + assert script_vars_2["y"] == 1 + + script_vars_3 = script_vars_2.enter_scope() + script_vars_3["x"] = 3 + script_vars_3["y"] = 3 + assert script_vars_3["x"] == 3 + assert script_vars_3["y"] == 3 + + script_vars_4 = script_vars_3.enter_scope() + assert script_vars_4["x"] == 3 + assert script_vars_4["y"] == 3 + + assert script_vars_4.exit_scope() is script_vars_3 + + assert script_vars_3._full_scope == {"x": 3, "y": 3} + assert script_vars_3.local_scope == {} + + assert script_vars_3.exit_scope() is script_vars_2 + + assert script_vars_2._full_scope == {"x": 3, "y": 3} + assert script_vars_2.local_scope == {"x": 3} + + assert script_vars_2.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": 1, "y": 3} + assert script_vars.local_scope == {"x": 1, "y": 3} + + +async def test_script_vars_parallel() -> None: + """Test script run variables parallel support.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 1, "z": 1}) + + script_vars_2a = script_vars.enter_scope(parallel=True) + script_vars_3a = script_vars_2a.enter_scope() + + script_vars_2b = script_vars.enter_scope(parallel=True) + script_vars_3b = script_vars_2b.enter_scope() + + script_vars_3a["x"] = "a" + script_vars_3a.assign_parallel_protected("y", "a") + + script_vars_3b["x"] = "b" + script_vars_3b.assign_parallel_protected("y", "b") + + assert script_vars_3a._full_scope == {"x": "b", "y": "a", "z": 1} + assert script_vars_3a.non_parallel_scope == {"x": "a", "y": "a"} + + assert script_vars_3b._full_scope == {"x": "b", "y": "b", "z": 1} + assert script_vars_3b.non_parallel_scope == {"x": "b", "y": "b"} + + assert script_vars_3a.exit_scope() is script_vars_2a + assert script_vars_2a.exit_scope() is script_vars + assert script_vars_3b.exit_scope() is script_vars_2b + assert script_vars_2b.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": "b", "y": 1, "z": 1} + assert script_vars.local_scope == {"x": "b", "y": 1, "z": 1} diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index f73808a0625..d07bb7458e9 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -7,7 +7,7 @@ import pytest import voluptuous as vol from homeassistant.helpers import selector -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util FAKE_UUID = "a266a680b608c32770e6c45bfe6b8411" @@ -77,7 +77,7 @@ def _test_selector( "selector": {selector_type: selector_instance.config} } # Test serialized selector can be dumped to YAML - yaml.dump(selector_instance.serialize()) + yaml_util.dump(selector_instance.serialize()) @pytest.mark.parametrize( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 6d03e09cdf7..142f7a23f81 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -36,11 +36,11 @@ from homeassistant.core import ( ) from homeassistant.helpers import ( area_registry as ar, + config_validation as cv, device_registry as dr, entity_registry as er, service, ) -import homeassistant.helpers.config_validation as cv from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -122,6 +122,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="test-floor", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_in_floor_a = ar.AreaEntry( id="area-a", @@ -130,6 +132,8 @@ def floor_area_mock(hass: HomeAssistant) -> None: floor_id="floor-a", icon=None, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, @@ -284,6 +288,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels={"label_area"}, picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) area_without_labels = ar.AreaEntry( id="area-no-labels", @@ -293,6 +299,8 @@ def label_mock(hass: HomeAssistant) -> None: icon=None, labels=set(), picture=None, + temperature_entity_id=None, + humidity_entity_id=None, ) mock_area_registry( hass, diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index 54c26997422..973d68b1f5c 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -10,7 +10,7 @@ import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant from homeassistant.helpers import sun -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util def test_next_events(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 628aea20900..016aedb2f99 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem @@ -1970,7 +1970,7 @@ def test_is_state(hass: HomeAssistant) -> None: def test_is_state_attr(hass: HomeAssistant) -> None: """Test is_state_attr method.""" - hass.states.async_set("test.object", "available", {"mode": "on"}) + hass.states.async_set("test.object", "available", {"mode": "on", "exists": None}) tpl = template.Template( """ {% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %} @@ -2003,6 +2003,22 @@ def test_is_state_attr(hass: HomeAssistant) -> None: ) assert tpl.async_render() == "test.object" + tpl = template.Template( + """ +{% if is_state_attr("test.object", "exists", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + tpl = template.Template( + """ +{% if is_state_attr("test.object", "noexist", None) %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "no" + def test_state_attr(hass: HomeAssistant) -> None: """Test state_attr method.""" @@ -2110,6 +2126,7 @@ async def test_state_translated( hass.states.async_set("domain.is_unknown", "unknown", attributes={}) config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) entity_registry.async_get_or_create( "light", "hue", @@ -5864,6 +5881,75 @@ async def test_floor_areas( assert info.rate_limit is None +async def test_floor_entities( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_entities function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_entities('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area1 = area_registry.async_create("Living room") + area2 = area_registry.async_create("Dining room") + area_registry.async_update(area1.id, floor_id=floor.floor_id) + area_registry.async_update(area2.id, floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "living_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "dining_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) + + # Get entities by floor ID + expected = ["light.hue_living_room", "light.hue_dining_room"] + info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Get entities by floor name + info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + async def test_labels( hass: HomeAssistant, label_registry: lr.LabelRegistry, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index d4a78807e2b..3593db9cf87 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -74,7 +74,7 @@ def test_load_translations_files_by_language( "name": "Other 4", "unit_of_measurement": "quantities", }, - "outlet": {"name": "Outlet " "{placeholder}"}, + "outlet": {"name": "Outlet {placeholder}"}, } }, "something": "else", diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 539762a60ff..3ad5754dada 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import logging from unittest.mock import AsyncMock, Mock, patch import urllib.error +import weakref import aiohttp from freezegun.api import FrozenDateTimeFactory @@ -12,7 +13,7 @@ import requests from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -898,3 +899,41 @@ async def test_config_entry(hass: HomeAssistant) -> None: hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + + +async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: + """Test listener subscribe/unsubscribe releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + And https://github.com/home-assistant/core/pull/137338 + """ + + class Subscriber: + _unsub: CALLBACK_TYPE | None = None + + def start_listen( + self, coordinator: update_coordinator.DataUpdateCoordinator + ) -> None: + self._unsub = coordinator.async_add_listener(lambda: None) + + def stop_listen(self) -> None: + self._unsub() + self._unsub = None + + coordinator = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test" + ) + subscriber = Subscriber() + subscriber.start_listen(coordinator) + + # Keep weak reference to the coordinator + weak_ref = weakref.ref(coordinator) + assert weak_ref() is not None + + # Unload the subscriber, then shutdown the coordinator + subscriber.stop_listen() + await coordinator.async_shutdown() + del coordinator + + # Ensure the coordinator is released + assert weak_ref() is None diff --git a/tests/patch_recorder.py b/tests/patch_recorder.py index 4993e84fc30..e0e66de19a5 100644 --- a/tests/patch_recorder.py +++ b/tests/patch_recorder.py @@ -6,7 +6,7 @@ from contextlib import contextmanager import sys # Patch recorder util session scope -from homeassistant.helpers import recorder as recorder_helper # noqa: E402 +from homeassistant.helpers import recorder as recorder_helper # Make sure homeassistant.components.recorder.util is not already imported assert "homeassistant.components.recorder.util" not in sys.modules diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 6c53e9832d9..efa3ca9523a 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1370,7 +1370,7 @@ def test_valid_generic( async def async_setup_entry( #@ hass: HomeAssistant, entry: {entry_annotation}, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: pass """, @@ -1402,7 +1402,7 @@ def test_invalid_generic( async def async_setup_entry( #@ hass: HomeAssistant, entry: {entry_annotation}, #@ - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: pass """, diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 519a5c21855..b667bdd3ddf 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -41,9 +41,9 @@ def test_requirement_override_markers() -> None: ): assert ( gen_requirements_all.process_action_requirement( - "env-canada==0.7.2", "pytest" + "env-canada==0.8.0", "pytest" ) - == "env-canada==0.7.2;python_version<'3.13'" + == "env-canada==0.8.0;python_version<'3.13'" ) assert ( gen_requirements_all.process_action_requirement("other==1.0", "pytest") diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index 51e56f4874e..08b532677f4 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/syrupy.py b/tests/syrupy.py index a3b3f763063..e028d5839cb 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,6 +109,12 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) + elif isinstance(data, dict) and set(data) == { + "conversation_id", + "response", + "continue_conversation", + }: + serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) elif isinstance(data, ConfigEntry): @@ -158,6 +164,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): attrs.asdict(data) | { "config_entries": ANY, + "config_entries_subentries": ANY, "id": ANY, } ) @@ -186,6 +193,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): attrs.asdict(data) | { "config_entry_id": ANY, + "config_subentry_id": ANY, "device_id": ANY, "id": ANY, "options": {k: dict(v) for k, v in data.options.items()}, @@ -200,6 +208,11 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): """Prepare a Home Assistant flow result for serialization.""" return FlowResultSnapshot(data | {"flow_id": ANY}) + @classmethod + def _serializable_conversation_result(cls, data: dict) -> SerializableData: + """Prepare a Home Assistant conversation result for serialization.""" + return data | {"conversation_id": ANY} + @classmethod def _serializable_issue_registry_entry( cls, data: ir.IssueEntry @@ -376,7 +389,7 @@ def override_syrupy_finish(self: SnapshotSession) -> int: with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) with open( - f".pytest_syrupy_{os.getenv("PYTEST_XDIST_WORKER")}_result", + f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", "w", encoding="utf-8", ) as f: diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 10ea64a6a61..4c6bc930667 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -1,7 +1,10 @@ """Test methods in backup_restore.""" +from collections.abc import Generator +import json from pathlib import Path import tarfile +from typing import Any from unittest import mock import pytest @@ -11,6 +14,23 @@ from homeassistant import backup_restore from .common import get_test_config_dir +@pytest.fixture(autouse=True) +def remove_restore_result_file() -> Generator[None, Any, Any]: + """Remove the restore result file.""" + yield + Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) + + +def restore_result_file_content() -> dict[str, Any] | None: + """Return the content of the restore result file.""" + try: + return json.loads( + Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") + ) + except FileNotFoundError: + return None + + @pytest.mark.parametrize( ("side_effect", "content", "expected"), [ @@ -87,6 +107,11 @@ def test_restoring_backup_that_does_not_exist() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_restoring_backup_when_instructions_can_not_be_read() -> None: @@ -98,6 +123,7 @@ def test_restoring_backup_when_instructions_can_not_be_read() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() is None def test_restoring_backup_that_is_not_a_file() -> None: @@ -121,6 +147,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_aborting_for_older_versions() -> None: @@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None: ), ): assert backup_restore.restore_backup(config_dir) is True + assert restore_result_file_content() == { + "error": ( + "You need at least Home Assistant version 9999.99.99 to restore this backup" + ), + "error_type": "ValueError", + "success": False, + } @pytest.mark.parametrize( @@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring( assert removed_directories == { Path(config_dir, d) for d in expected_removed_directories } + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( @@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore( assert mock_unlink.call_count == unlink_calls for call in mock_unlink.mock_calls: assert call.args[0] == backup_file_path + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index dd23d4e9709..f42fbb9f4ef 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -261,7 +261,7 @@ async def test_protect_path_read_bytes(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_bytes(), # noqa: ASYNC230 + Path("/config/data_not_exist").read_bytes(), ): pass @@ -274,7 +274,7 @@ async def test_protect_path_read_text(caplog: pytest.LogCaptureFixture) -> None: block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data_not_exist").read_text(encoding="utf8"), # noqa: ASYNC230 + Path("/config/data_not_exist").read_text(encoding="utf8"), ): pass @@ -287,7 +287,7 @@ async def test_protect_path_write_bytes(caplog: pytest.LogCaptureFixture) -> Non block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_bytes(b"xxx"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_bytes(b"xxx"), ): pass @@ -300,7 +300,7 @@ async def test_protect_path_write_text(caplog: pytest.LogCaptureFixture) -> None block_async_io.enable() with ( contextlib.suppress(FileNotFoundError), - Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), # noqa: ASYNC230 + Path("/config/data/not/exist").write_text("xxx", encoding="utf8"), ): pass diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c1c532c94b5..e89d038f8ce 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,8 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant import bootstrap, loader, runner -import homeassistant.config as config_util +from homeassistant import bootstrap, config as config_util, core, loader, runner from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( BASE_PLATFORMS, @@ -788,6 +787,9 @@ async def test_setup_hass_recovery_mode( ) -> None: """Test it works.""" with ( + patch( + "homeassistant.core.HomeAssistant", wraps=core.HomeAssistant + ) as mock_hass, patch("homeassistant.components.browser.setup") as browser_setup, patch( "homeassistant.config_entries.ConfigEntries.async_domains", @@ -806,6 +808,8 @@ async def test_setup_hass_recovery_mode( ), ) + mock_hass.assert_called_once() + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 @@ -1091,7 +1095,7 @@ async def test_tasks_logged_that_block_stage_1( patch.object(bootstrap, "STAGE_1_TIMEOUT", 0), patch.object(bootstrap, "COOLDOWN_TIME", 0), patch.object( - bootstrap, "STAGE_1_INTEGRATIONS", [*original_stage_1, "normal_integration"] + bootstrap, "STAGE_1_INTEGRATIONS", {*original_stage_1, "normal_integration"} ), ): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) @@ -1177,7 +1181,7 @@ async def test_bootstrap_is_cancellation_safe( @pytest.mark.parametrize("load_registries", [False]) async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: """Test setting up an empty integrations does not raise.""" - await bootstrap.async_setup_multi_components(hass, set(), {}) + await bootstrap._async_setup_multi_components(hass, set(), {}) await hass.async_block_till_done() @@ -1312,7 +1316,7 @@ async def test_bootstrap_dependencies( ), ): bootstrap.async_set_domains_to_be_loaded(hass, {integration}) - await bootstrap.async_setup_multi_components(hass, {integration}, {}) + await bootstrap._async_setup_multi_components(hass, {integration}, {}) await hass.async_block_till_done() for assertion in assertions: @@ -1374,11 +1378,11 @@ async def test_pre_import_no_requirements(hass: HomeAssistant) -> None: @pytest.mark.timeout(20) -async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: - """Test that the bootstrap does not preload stage 1 integrations. +async def test_bootstrap_does_not_preimport_stage_1_integrations() -> None: + """Test that the bootstrap does not preimport stage 1 integrations. If this test fails it means that stage1 integrations are being - loaded too soon and will not get their requirements updated + imported too soon and will not get their requirements updated before they are loaded at runtime. """ @@ -1392,13 +1396,9 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: assert process.returncode == 0 decoded_stdout = stdout.decode() - disallowed_integrations = bootstrap.STAGE_1_INTEGRATIONS.copy() - # zeroconf is a top level dep now - disallowed_integrations.remove("zeroconf") - # Ensure no stage1 integrations have been imported # as a side effect of importing the pre-imports - for integration in disallowed_integrations: + for integration in bootstrap.STAGE_1_INTEGRATIONS: assert f"homeassistant.components.{integration}" not in decoded_stdout @@ -1408,7 +1408,7 @@ async def test_cancellation_does_not_leak_upward_from_async_setup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting up an integration that raises asyncio.CancelledError.""" - await bootstrap.async_setup_multi_components( + await bootstrap._async_setup_multi_components( hass, {"test_package_raises_cancelled_error"}, {} ) await hass.async_block_till_done() @@ -1429,12 +1429,12 @@ async def test_cancellation_does_not_leak_upward_from_async_setup_entry( domain="test_package_raises_cancelled_error_config_entry", data={} ) entry.add_to_hass(hass) - await bootstrap.async_setup_multi_components( + await bootstrap._async_setup_multi_components( hass, {"test_package_raises_cancelled_error_config_entry"}, {} ) await hass.async_block_till_done() - await bootstrap.async_setup_multi_components(hass, {"test_package"}, {}) + await bootstrap._async_setup_multi_components(hass, {"test_package"}, {}) await hass.async_block_till_done() assert ( "Error setting up entry Mock Title for test_package_raises_cancelled_error_config_entry" @@ -1528,3 +1528,46 @@ def test_should_rollover_is_always_false() -> None: ).shouldRollover(Mock()) is False ) + + +async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: + """Verify stage 0 not load base platforms before recorder. + + If a stage 0 integration has a base platform in its dependencies and + it loads before the recorder, it may load integrations that expect + the recorder to be loaded. We need to ensure that no stage 0 integration + has a base platform in its dependencies that loads before the recorder. + """ + integrations_before_recorder: set[str] = set() + for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: + integrations_before_recorder |= integrations + if "recorder" in integrations: + break + + integrations_or_execs = await loader.async_get_integrations( + hass, integrations_before_recorder + ) + integrations: list[Integration] = [] + resolve_deps_tasks: list[asyncio.Task[bool]] = [] + for integration in integrations_or_execs.values(): + assert not isinstance(integrations_or_execs, Exception) + integrations.append(integration) + resolve_deps_tasks.append(integration.resolve_dependencies()) + + await asyncio.gather(*resolve_deps_tasks) + base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + for integration in integrations: + domain_with_base_platforms_deps = BASE_PLATFORMS.intersection( + integration.all_dependencies + ) + assert not domain_with_base_platforms_deps, ( + f"{integration.domain} has base platforms in dependencies: " + f"{domain_with_base_platforms_deps}" + ) + integration_top_level_files = base_platform_py_files.intersection( + integration._top_level_files + ) + assert not integration_top_level_files, ( + f"{integration.domain} has base platform files in top level files: " + f"{integration_top_level_files}" + ) diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index dfdee65b2b0..d6e730aae5e 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -7,11 +7,8 @@ import pytest from homeassistant.bootstrap import ( CORE_INTEGRATIONS, - DEBUGGER_INTEGRATIONS, DEFAULT_INTEGRATIONS, - FRONTEND_INTEGRATIONS, - LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - RECORDER_INTEGRATIONS, + STAGE_0_INTEGRATIONS, STAGE_1_INTEGRATIONS, ) @@ -21,11 +18,12 @@ from homeassistant.bootstrap import ( "component", sorted( { - *DEBUGGER_INTEGRATIONS, *CORE_INTEGRATIONS, - *LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - *FRONTEND_INTEGRATIONS, - *RECORDER_INTEGRATIONS, + *( + domain + for name, domains, timeout in STAGE_0_INTEGRATIONS + for domain in domains + ), *STAGE_1_INTEGRATIONS, *DEFAULT_INTEGRATIONS, } diff --git a/tests/test_config.py b/tests/test_config.py index c8c5b081119..569af3238d0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,8 +15,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol import yaml -from homeassistant import loader -import homeassistant.config as config_util +from homeassistant import config as config_util, loader from homeassistant.const import CONF_PACKAGES, __version__ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigValidationError, HomeAssistantError diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index aba85a35349..66aa29d95d1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator +from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import logging import re @@ -16,7 +17,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, @@ -39,14 +39,15 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import entity_registry as er, frame, issue_registry as ir from homeassistant.helpers.discovery_flow import DiscoveryKey -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from .common import ( @@ -461,14 +462,22 @@ async def test_remove_entry( assert result return result - mock_remove_entry = AsyncMock(return_value=None) + remove_entry_calls = [] + + async def mock_remove_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Mock removing an entry.""" + # Check that the entry is no longer in the config entries + assert not hass.config_entries.async_get_entry(entry.entry_id) + remove_entry_calls.append(None) entity = MockEntity(unique_id="1234", name="Test Entity") async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" async_add_entities([entity]) @@ -511,6 +520,7 @@ async def test_remove_entry( assert len(entity_registry.entities) == 1 entity_entry = list(entity_registry.entities.values())[0] assert entity_entry.config_entry_id == entry.entry_id + assert entity_entry.config_subentry_id is None # Remove entry result = await manager.async_remove("test2") @@ -520,7 +530,7 @@ async def test_remove_entry( assert result == {"require_restart": False} # Check the remove callback was invoked. - assert mock_remove_entry.call_count == 1 + assert len(remove_entry_calls) == 1 # Check that config entry was removed. assert manager.async_entry_ids() == ["test1", "test3"] @@ -534,6 +544,118 @@ async def test_remove_entry( assert not entity_entry_list +async def test_remove_subentry( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we can remove a subentry.""" + subentry_id = "blabla" + update_listener_calls = [] + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + await hass.config_entries.async_forward_entry_setups(entry, ["light"]) + return True + + mock_remove_entry = AsyncMock(return_value=None) + + entry_entity = MockEntity(unique_id="0001", name="Test Entry Entity") + subentry_entity = MockEntity(unique_id="0002", name="Test Subentry Entity") + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + async_add_entities([entry_entity]) + async_add_entities([subentry_entity], config_subentry_id=subentry_id) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="unique", + title="Mock title", + ) + ] + ) + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == {} + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Check entity states got added + assert hass.states.get("light.test_entry_entity") is not None + assert hass.states.get("light.test_subentry_entity") is not None + assert len(hass.states.async_all()) == 2 + + # Check entities got added to entity registry + assert len(entity_registry.entities) == 2 + entry_entity_entry = entity_registry.entities["light.test_entry_entity"] + assert entry_entity_entry.config_entry_id == entry.entry_id + assert entry_entity_entry.config_subentry_id is None + subentry_entity_entry = entity_registry.entities["light.test_subentry_entity"] + assert subentry_entity_entry.config_entry_id == entry.entry_id + assert subentry_entity_entry.config_subentry_id == subentry_id + + # Remove subentry + result = manager.async_remove_subentry(entry, subentry_id) + assert len(update_listener_calls) == 1 + await hass.async_block_till_done() + + # Check that remove went well + assert result is True + + # Check the remove callback was not invoked. + assert mock_remove_entry.call_count == 0 + + # Check that the config subentry was removed. + assert entry.subentries == {} + + # Check that entity state has been removed + assert hass.states.get("light.test_entry_entity") is not None + assert hass.states.get("light.test_subentry_entity") is None + assert len(hass.states.async_all()) == 1 + + # Check that entity registry entry has been removed + entity_entry_list = list(entity_registry.entities) + assert entity_entry_list == ["light.test_entry_entity"] + + # Try to remove the subentry again + with pytest.raises(config_entries.UnknownSubEntry): + manager.async_remove_subentry(entry, subentry_id) + assert len(update_listener_calls) == 1 + + async def test_remove_entry_non_unique_unique_id( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -905,7 +1027,7 @@ async def test_entries_excludes_ignore_and_disabled( async def test_saving_and_loading( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any] ) -> None: """Test that we're saving and loading correctly.""" mock_integration( @@ -922,7 +1044,20 @@ async def test_saving_and_loading( async def async_step_user(self, user_input=None): """Test user step.""" await self.async_set_unique_id("unique") - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) + subentries = [ + config_entries.ConfigSubentryData( + data={"foo": "bar"}, subentry_type="test", title="subentry 1" + ), + config_entries.ConfigSubentryData( + data={"sun": "moon"}, + subentry_type="test", + title="subentry 2", + unique_id="very_unique", + ), + ] + return self.async_create_entry( + title="Test Title", data={"token": "abcd"}, subentries=subentries + ) with mock_config_flow("test", TestFlow): await hass.config_entries.flow.async_init( @@ -971,6 +1106,100 @@ async def test_saving_and_loading( # To execute the save await hass.async_block_till_done() + stored_data = hass_storage["core.config_entries"] + assert stored_data == { + "data": { + "entries": [ + { + "created_at": ANY, + "data": { + "token": "abcd", + }, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": True, + "pref_disable_polling": True, + "source": "user", + "subentries": [ + { + "data": {"foo": "bar"}, + "subentry_id": ANY, + "subentry_type": "test", + "title": "subentry 1", + "unique_id": None, + }, + { + "data": {"sun": "moon"}, + "subentry_id": ANY, + "subentry_type": "test", + "title": "subentry 2", + "unique_id": "very_unique", + }, + ], + "title": "Test Title", + "unique_id": "unique", + "version": 5, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": "blah", "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": ["a", "b"], "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + ], + }, + "key": "core.config_entries", + "minor_version": 5, + "version": 1, + } + # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) await manager.async_initialize() @@ -983,6 +1212,25 @@ async def test_saving_and_loading( ): assert orig.as_dict() == loaded.as_dict() + hass.config_entries.async_update_entry( + entry_1, + pref_disable_polling=False, + pref_disable_new_entities=False, + ) + + # To trigger the call_later + freezer.tick(1.0) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + + # Assert no data is lost when storing again + expected_stored_data = stored_data + expected_stored_data["data"]["entries"][0]["modified_at"] = ANY + expected_stored_data["data"]["entries"][0]["pref_disable_new_entities"] = False + expected_stored_data["data"]["entries"][0]["pref_disable_polling"] = False + assert hass_storage["core.config_entries"] == expected_stored_data | {} + @freeze_time("2024-02-14 12:00:00") async def test_as_dict(snapshot: SnapshotAssertion) -> None: @@ -1144,7 +1392,7 @@ async def test_discovery_notification( notifications = async_get_persistent_notifications(hass) assert "config_entry_discovery" not in notifications - # Start first discovery flow to assert that reconfigure notification fires + # Start first discovery flow to assert that discovery notification fires flow1 = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY} ) @@ -1416,6 +1664,117 @@ async def test_update_entry_options_and_trigger_listener( assert len(update_listener_calls) == 1 +async def test_updating_subentry_data( + manager: config_entries.ConfigEntries, freezer: FrozenDateTimeFactory +) -> None: + """Test that we can update an entry data.""" + created = dt_util.utcnow() + subentry_id = "blabla" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="unique", + title="Mock title", + ) + ] + ) + subentry = entry.subentries[subentry_id] + entry.add_to_manager(manager) + + assert len(manager.async_entries()) == 1 + assert manager.async_entries()[0] == entry + assert entry.created_at == created + assert entry.modified_at == created + + freezer.tick() + + assert manager.async_update_subentry(entry, subentry) is False + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + } + assert entry.modified_at == created + assert manager.async_entries()[0].modified_at == created + + freezer.tick() + modified = dt_util.utcnow() + + assert manager.async_update_subentry(entry, subentry, data={"second": True}) is True + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + } + assert entry.modified_at == modified + assert manager.async_entries()[0].modified_at == modified + + +async def test_update_subentry_and_trigger_listener( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can update subentry and trigger listener.""" + entry = MockConfigEntry(domain="test", options={"first": True}) + entry.add_to_manager(manager) + update_listener_calls = [] + + subentry = config_entries.ConfigSubentry( + data={"test": "test"}, + subentry_type="test", + unique_id="test", + title="Mock title", + ) + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == expected_subentries + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + + expected_subentries = {subentry.subentry_id: subentry} + assert manager.async_add_subentry(entry, subentry) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 1 + + assert ( + manager.async_update_subentry( + entry, + subentry, + data={"test": "test2"}, + title="New title", + unique_id="test2", + ) + is True + ) + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 2 + + expected_subentries = {} + assert manager.async_remove_subentry(entry, subentry.subentry_id) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 3 + + async def test_setup_raise_not_ready( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1742,20 +2101,465 @@ async def test_entry_options_unknown_config_entry( mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) - class TestFlow: - """Test flow.""" - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Test options flow.""" - with pytest.raises(config_entries.UnknownEntry): await manager.options.async_create_flow( "blah", context={"source": "test"}, data=None ) +async def test_create_entry_subentries( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test a config entry being created with subentries.""" + + subentrydata = config_entries.ConfigSubentryData( + data={"test": "test"}, + title="Mock title", + subentry_type="test", + unique_id="test", + ) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + data={"data": "data", "subentry": subentrydata}, + ) + ) + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Test import step creating entry, with subentry.""" + return self.async_create_entry( + title="title", + data={"example": user_input["data"]}, + subentries=[user_input["subentry"]], + ) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + assert await async_setup_component(hass, "comp", {}) + + await hass.async_block_till_done() + + assert len(async_setup_entry.mock_calls) == 1 + + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + assert entries[0].supported_subentry_types == {} + assert entries[0].data == {"example": "data"} + assert len(entries[0].subentries) == 1 + subentry_id = list(entries[0].subentries)[0] + subentry = config_entries.ConfigSubentry( + data=subentrydata["data"], + subentry_id=subentry_id, + subentry_type="test", + title=subentrydata["title"], + unique_id="test", + ) + assert entries[0].subentries == {subentry_id: subentry} + + +async def test_entry_subentry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can add a subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", + }, + ) + + assert entry.data == {"first": True} + assert entry.options == {} + subentry_id = list(entry.subentries)[0] + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + } + assert entry.supported_subentry_types == { + "test": {"supports_reconfigure": False} + } + + +async def test_subentry_flow( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can execute a subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + async def async_step_user(self, user_input=None): + return self.async_create_entry( + title="Mock title", + data={"second": True}, + unique_id="test", + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), context={"source": "user"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + assert entry.data == {"first": True} + assert entry.options == {} + subentry_id = list(entry.subentries)[0] + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + } + assert entry.supported_subentry_types == { + "test": {"supports_reconfigure": False} + } + + +async def test_entry_subentry_non_string( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test adding an invalid subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + with pytest.raises(HomeAssistantError): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": 123, + }, + ) + + +@pytest.mark.parametrize("context", [None, {}, {"bla": "bleh"}]) +async def test_entry_subentry_no_context( + hass: HomeAssistant, manager: config_entries.ConfigEntries, context: dict | None +) -> None: + """Test starting a subentry flow without "source" in context.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow), pytest.raises(KeyError): + await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context=context, data=None + ) + + +@pytest.mark.parametrize( + ("unique_id", "expected_result"), + [(None, does_not_raise()), ("test", pytest.raises(HomeAssistantError))], +) +async def test_entry_subentry_duplicate( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unique_id: str | None, + expected_result: AbstractContextManager, +) -> None: + """Test adding a duplicated subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry( + domain="test", + data={"first": True}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="blabla", + subentry_type="test", + title="Mock title", + unique_id=unique_id, + ) + ], + ) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + with expected_result: + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": unique_id, + }, + ) + + +async def test_entry_subentry_abort( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + assert await manager.subentries.async_finish_flow( + flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"} + ) + + +async def test_entry_subentry_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for an unknown config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + with pytest.raises(config_entries.UnknownEntry): + await manager.subentries.async_create_flow( + ("blah", "blah"), context={"source": "test"}, data=None + ) + + +async def test_entry_subentry_deleted_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to finish a subentry flow for a deleted config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + await hass.config_entries.async_remove(entry.entry_id) + + with pytest.raises(config_entries.UnknownEntry): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", + }, + ) + + +async def test_entry_subentry_unsupported_subentry_type( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + ( + entry.entry_id, + "unknown", + ), + context={"source": "test"}, + data=None, + ) + + +async def test_entry_subentry_unsupported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + async def test_entry_setup_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1815,29 +2619,49 @@ async def test_entry_setup_invalid_state( assert entry.state is state -async def test_entry_unload_succeed( - hass: HomeAssistant, manager: config_entries.ConfigEntries +@pytest.mark.parametrize( + ("unload_result", "expected_result", "expected_state", "has_runtime_data"), + [ + (True, True, config_entries.ConfigEntryState.NOT_LOADED, False), + (False, False, config_entries.ConfigEntryState.FAILED_UNLOAD, True), + ], +) +async def test_entry_unload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unload_result: bool, + expected_result: bool, + expected_state: config_entries.ConfigEntryState, + has_runtime_data: bool, ) -> None: """Test that we can unload an entry.""" - unloads_called = [] + unload_entry_calls = [] - async def verify_runtime_data(*args): + @callback + def verify_runtime_data() -> None: """Verify runtime data.""" assert entry.runtime_data == 2 - unloads_called.append(args) - return True + + async def async_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unload entry.""" + unload_entry_calls.append(None) + verify_runtime_data() + assert entry.state is config_entries.ConfigEntryState.UNLOAD_IN_PROGRESS + return unload_result entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) entry.async_on_unload(verify_runtime_data) entry.runtime_data = 2 - mock_integration(hass, MockModule("comp", async_unload_entry=verify_runtime_data)) + mock_integration(hass, MockModule("comp", async_unload_entry=async_unload_entry)) - assert await manager.async_unload(entry.entry_id) - assert len(unloads_called) == 2 - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert not hasattr(entry, "runtime_data") + assert await manager.async_unload(entry.entry_id) == expected_result + assert len(unload_entry_calls) == 1 + assert entry.state is expected_state + assert hasattr(entry, "runtime_data") == has_runtime_data @pytest.mark.parametrize( @@ -2645,9 +3469,7 @@ async def test_unique_id_from_discovery_in_setup_retry( VERSION = 1 - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> FlowResult: + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Test dhcp step.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -2683,7 +3505,7 @@ async def test_unique_id_from_discovery_in_setup_retry( discovery_result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( + data=DhcpServiceInfo( hostname="any", ip=host, macaddress=unique_id, @@ -3911,21 +4733,20 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry) is False - for change in ( - {"data": {"second": True, "third": 456}}, - {"data": {"second": True}}, - {"minor_version": 2}, - {"options": {"hello": True}}, - {"pref_disable_new_entities": True}, - {"pref_disable_polling": True}, - {"title": "sometitle"}, - {"unique_id": "abcd1234"}, - {"version": 2}, + for change, expected_value in ( + ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}), + ({"data": {"second": True}}, {"second": True}), + ({"minor_version": 2}, 2), + ({"options": {"hello": True}}, {"hello": True}), + ({"pref_disable_new_entities": True}, True), + ({"pref_disable_polling": True}, True), + ({"title": "sometitle"}, "sometitle"), + ({"unique_id": "abcd1234"}, "abcd1234"), + ({"version": 2}, 2), ): assert manager.async_update_entry(entry, **change) is True key = next(iter(change)) - value = next(iter(change.values())) - assert getattr(entry, key) == value + assert getattr(entry, key) == expected_value assert manager.async_update_entry(entry, **change) is False assert manager.async_entry_for_domain_unique_id("test", "abc123") is None @@ -3975,6 +4796,136 @@ async def test_entry_reload_calls_on_unload_listeners( assert entry.state is config_entries.ConfigEntryState.LOADED +@pytest.mark.parametrize( + ("source_state", "target_state", "transition_method_name", "call_count"), + [ + ( + config_entries.ConfigEntryState.NOT_LOADED, + config_entries.ConfigEntryState.LOADED, + "async_setup", + 2, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.NOT_LOADED, + "async_unload", + 2, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.LOADED, + "async_reload", + 4, + ), + ], +) +async def test_entry_state_change_calls_listener( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + source_state: config_entries.ConfigEntryState, + target_state: config_entries.ConfigEntryState, + transition_method_name: str, + call_count: int, +) -> None: + """Test listeners get called on entry state changes.""" + entry = MockConfigEntry(domain="comp", state=source_state) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock() + entry.async_on_state_change(mock_state_change_callback) + + transition_method = getattr(manager, transition_method_name) + await transition_method(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == call_count + assert entry.state is target_state + + +async def test_entry_state_change_listener_removed( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, +) -> None: + """Test state_change listener can be removed.""" + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock() + remove = entry.async_on_state_change(mock_state_change_callback) + + await manager.async_setup(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == 2 + assert entry.state is config_entries.ConfigEntryState.LOADED + + remove() + + await manager.async_unload(entry.entry_id) + + # the listener should no longer be called + assert len(mock_state_change_callback.mock_calls) == 2 + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_entry_state_change_error_does_not_block_transition( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we transition states normally even if the callback throws in on_state_change.""" + entry = MockConfigEntry( + title="test", domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock(side_effect=Exception()) + + entry.async_on_state_change(mock_state_change_callback) + + await manager.async_setup(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == 2 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert "Error calling on_state_change callback for test (comp)" in caplog.text + + async def test_setup_raise_entry_error( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -5445,6 +6396,207 @@ async def test_update_entry_and_reload( assert len(comp.async_unload_entry.mock_calls) == calls_entry_load_unload[1] +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + None, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + None, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + None, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + None, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + None, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + ValueError, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: type[Exception] | None, +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + comp = MockModule("comp") + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + **kwargs, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + err: Exception + with mock_config_flow("comp", TestFlow): + try: + result = await entry.start_subentry_reconfigure_flow( + hass, "test", subentry_id + ) + except Exception as ex: # noqa: BLE001 + err = ex + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + if raises: + assert isinstance(err, raises) + else: + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: + """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + + comp = MockModule("comp") + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_create_entry(title="New Subentry", data={}) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises(ValueError, match="Source is reconfigure, expected user"), + ): + await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + + await hass.async_block_till_done() + + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + title="Test", + unique_id="1234", + ) + } + + @pytest.mark.parametrize("unique_id", [["blah", "bleh"], {"key": "value"}]) async def test_unhashable_unique_id_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any @@ -5459,6 +6611,7 @@ async def test_unhashable_unique_id_fails( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -5494,6 +6647,7 @@ async def test_unhashable_unique_id_fails_on_update( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5524,6 +6678,7 @@ async def test_string_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5566,6 +6721,7 @@ async def test_hashable_unique_id( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -5600,6 +6756,7 @@ async def test_no_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=None, version=1, @@ -5983,6 +7140,23 @@ async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: hass.config_entries.async_update_entry(entry, unique_id="new_id") +async def test_updating_non_added_subentry_raises(hass: HomeAssistant) -> None: + """Test updating a non added entry raises UnknownEntry.""" + entry = MockConfigEntry(domain="test") + subentry = config_entries.ConfigSubentry( + data={}, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + + with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id): + hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") + entry.add_to_hass(hass) + with pytest.raises(config_entries.UnknownSubEntry, match=subentry.subentry_id): + hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") + + async def test_reload_during_setup(hass: HomeAssistant) -> None: """Test reload during setup waits.""" entry = MockConfigEntry(domain="comp", data={"value": "initial"}) @@ -6064,7 +7238,7 @@ async def test_raise_wrong_exception_in_forwarded_platform( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" raise exc @@ -6138,7 +7312,7 @@ async def test_config_entry_unloaded_during_platform_setups( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await asyncio.sleep(0) @@ -6210,7 +7384,7 @@ async def test_non_awaited_async_forward_entry_setups( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await forward_event.wait() @@ -6282,7 +7456,7 @@ async def test_non_awaited_async_forward_entry_setup( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await forward_event.wait() @@ -6357,7 +7531,7 @@ async def test_config_entry_unloaded_during_platform_setup( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await asyncio.sleep(0) @@ -6432,7 +7606,7 @@ async def test_config_entry_late_platform_setup( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await asyncio.sleep(0) @@ -6524,6 +7698,7 @@ async def test_migration_from_1_2( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "import", + "subentries": {}, "title": "Sun", "unique_id": None, "version": 1, @@ -6850,7 +8025,7 @@ async def test_get_reauth_entry( async def test_get_reconfigure_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test _get_context_entry behavior.""" + """Test _get_reconfigure_entry behavior.""" entry = MockConfigEntry( title="test_title", domain="test", @@ -6925,6 +8100,114 @@ async def test_get_reconfigure_entry( assert result["reason"] == "Source is user, expected reconfigure: -" +async def test_subentry_get_reconfigure_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test subentry _get_reconfigure_entry and _get_reconfigure_subentry behavior.""" + subentry_id = "mock_subentry_id" + entry = MockConfigEntry( + data={}, + domain="test", + entry_id="mock_entry_id", + title="entry_title", + unique_id="entry_unique_id", + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + + entry.add_to_hass(hass) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + """Test user step.""" + return await self._async_step_confirm() + + async def async_step_reconfigure(self, user_input=None): + """Test reauth step.""" + return await self._async_step_confirm() + + async def _async_step_confirm(self): + """Confirm input.""" + try: + entry = self._get_reconfigure_entry() + except ValueError as err: + reason = str(err) + else: + reason = f"Found entry {entry.title}" + try: + entry_id = self._reconfigure_entry_id + except ValueError: + reason = f"{reason}: -" + else: + reason = f"{reason}: {entry_id}" + + try: + subentry = self._get_reconfigure_subentry() + except ValueError as err: + reason = f"{reason}/{err}" + except config_entries.UnknownSubEntry: + reason = f"{reason}/Subentry not found" + else: + reason = f"{reason}/Found subentry {subentry.title}" + try: + subentry_id = self._reconfigure_subentry_id + except ValueError: + reason = f"{reason}: -" + else: + reason = f"{reason}: {subentry_id}" + return self.async_abort(reason=reason) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + # A reconfigure flow finds the config entry + with mock_config_flow("test", TestFlow): + result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + assert ( + result["reason"] + == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id" + ) + + # The subentry_id does not exist + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "subentry_id": "01JRemoved", + }, + ) + assert ( + result["reason"] + == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" + ) + + # A user flow does not have access to the config entry or subentry + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} + ) + assert ( + result["reason"] + == "Source is user, expected reconfigure: -/Source is user, expected reconfigure: -" + ) + + async def test_reauth_helper_alignment( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7266,9 +8549,9 @@ async def test_unique_id_collision_issues( mock_setup_entry = AsyncMock(return_value=True) for i in range(3): mock_integration( - hass, MockModule(f"test{i+1}", async_setup_entry=mock_setup_entry) + hass, MockModule(f"test{i + 1}", async_setup_entry=mock_setup_entry) ) - mock_platform(hass, f"test{i+1}.config_flow", None) + mock_platform(hass, f"test{i + 1}.config_flow", None) test2_group_1: list[MockConfigEntry] = [] test2_group_2: list[MockConfigEntry] = [] @@ -7616,3 +8899,63 @@ async def test_add_description_placeholder_automatically_not_overwrites( result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) assert result["type"] == FlowResultType.FORM assert result["description_placeholders"] == {"name": "Custom title"} + + +@pytest.mark.parametrize( + ("domain", "expected_log"), + [ + ("some_integration", True), + ("mobile_app", False), + ], +) +async def test_create_entry_existing_unique_id( + hass: HomeAssistant, + domain: str, + expected_log: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test to highlight unexpected behavior on create_entry.""" + entry = MockConfigEntry( + title="From config flow", + domain=domain, + entry_id="01J915Q6T9F6G5V0QJX6HBC94T", + data={"host": "any", "port": 123}, + unique_id="mock-unique-id", + ) + entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(domain)) == 1 + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule(domain, async_setup_entry=mock_setup_entry)) + mock_platform(hass, f"{domain}.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + return self.async_create_entry(title="mock-title", data={}) + + with ( + mock_config_flow(domain, TestFlow), + patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + ): + result = await hass.config_entries.flow.async_init( + domain, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert len(hass.config_entries.async_entries(domain)) == 1 + + log_text = ( + f"Detected that integration '{domain}' creates a config entry " + "when another entry with the same unique ID exists. Please " + "create a bug report at https:" + ) + assert (log_text in caplog.text) == expected_log diff --git a/tests/test_core.py b/tests/test_core.py index 60b907d57ca..ceab3ce327c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -20,6 +20,7 @@ import pytest from pytest_unordered import unordered import voluptuous as vol +from homeassistant import core as ha from homeassistant.const import ( ATTR_FRIENDLY_NAME, EVENT_CALL_SERVICE, @@ -35,7 +36,6 @@ from homeassistant.const import ( EVENT_STATE_REPORTED, MATCH_ALL, ) -import homeassistant.core as ha from homeassistant.core import ( CoreState, HassJob, @@ -59,8 +59,8 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from .common import ( diff --git a/tests/test_core_config.py b/tests/test_core_config.py index dae50bae097..2723c8e7196 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -46,7 +46,7 @@ from homeassistant.util.unit_system import ( UnitSystem, ) -from .common import MockUser, async_capture_events +from .common import MockEntityPlatform, MockUser, async_capture_events def test_core_config_schema() -> None: @@ -181,7 +181,8 @@ def test_validate_stun_or_turn_url() -> None: invalid_urls = ( "custom_stun_server", "custom_stun_server:3478", - "bum:custom_stun_server:3478" "http://blah.com:80", + "bum:custom_stun_server:3478", + "http://blah.com:80", ) valid_urls = ( @@ -221,6 +222,7 @@ async def _compute_state(hass: HomeAssistant, config: dict[str, Any]) -> State | entity = Entity() entity.entity_id = "test.test" entity.hass = hass + entity.platform = MockEntityPlatform(hass) entity.schedule_update_ha_state() await hass.async_block_till_done() diff --git a/tests/test_loader.py b/tests/test_loader.py index 4c3c4eb309f..8afe800144c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2039,3 +2039,59 @@ async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: json_loads(json_dumps(integration.manifest_json_fragment)) == integration.manifest ) + + +async def test_async_get_integrations_multiple_non_existent( + hass: HomeAssistant, +) -> None: + """Test async_get_integrations with multiple non-existent integrations.""" + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert isinstance(integrations["does_not_exist"], loader.IntegrationNotFound) + + async def slow_load_failure( + *args: Any, **kwargs: Any + ) -> dict[str, loader.Integration]: + await asyncio.sleep(0.1) + return {} + + with patch.object(hass, "async_add_executor_job", slow_load_failure): + task1 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist", "does_not_exist2"]) + ) + # Task one should now be waiting for executor job + task2 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist"]) + ) + # Task two should be waiting for the futures created in task one + task3 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist2", "does_not_exist"]) + ) + # Task three should be waiting for the futures created in task one + integrations_1 = await task1 + assert isinstance(integrations_1["does_not_exist"], loader.IntegrationNotFound) + assert isinstance(integrations_1["does_not_exist2"], loader.IntegrationNotFound) + integrations_2 = await task2 + assert isinstance(integrations_2["does_not_exist"], loader.IntegrationNotFound) + integrations_3 = await task3 + assert isinstance(integrations_3["does_not_exist2"], loader.IntegrationNotFound) + assert isinstance(integrations_3["does_not_exist"], loader.IntegrationNotFound) + + # Make sure IntegrationNotFound is not cached + # so configuration errors can be fixed as to + # not prevent Home Assistant from being restarted + integration = loader.Integration( + hass, + "custom_components.does_not_exist", + None, + { + "name": "Does not exist", + "domain": "does_not_exist", + }, + ) + with patch.object( + loader, + "_resolve_integrations_from_root", + return_value={"does_not_exist": integration}, + ): + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert integrations["does_not_exist"] is integration diff --git a/tests/test_setup.py b/tests/test_setup.py index 2d15c670cf7..bb221c7cb4c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -363,20 +363,24 @@ async def test_component_failing_setup(hass: HomeAssistant) -> None: async def test_component_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" - setup.async_set_domains_to_be_loaded(hass, {"comp"}) + domain = "comp" + setup.async_set_domains_to_be_loaded(hass, {domain}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Raise exception.""" raise Exception("fail!") # noqa: TRY002 - mock_integration(hass, MockModule("comp", setup=exception_setup)) + mock_integration(hass, MockModule(domain, setup=exception_setup)) - assert not await setup.async_setup_component(hass, "comp", {}) - assert "comp" not in hass.config.components + assert not await setup.async_setup_component(hass, domain, {}) + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components async def test_component_base_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" + domain = "comp" setup.async_set_domains_to_be_loaded(hass, {"comp"}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -389,7 +393,69 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert "comp" not in hass.config.components + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components + + +async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: + """Test async_set_domains_to_be_loaded.""" + domain_good = "comp_good" + domain_bad = "comp_bad" + domain_base_exception = "comp_base_exception" + domain_exception = "comp_exception" + domains = {domain_good, domain_bad, domain_exception, domain_base_exception} + setup.async_set_domains_to_be_loaded(hass, domains) + + assert set(hass.data[setup.DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + + # Calling async_set_domains_to_be_loaded again should not create new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert setup_done == hass.data[setup.DATA_SETUP_DONE] + + def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Success.""" + return True + + def bad_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Fail.""" + return False + + def base_exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise BaseException("fail!") # noqa: TRY002 + + def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise Exception("fail!") # noqa: TRY002 + + mock_integration(hass, MockModule(domain_good, setup=good_setup)) + mock_integration(hass, MockModule(domain_bad, setup=bad_setup)) + mock_integration( + hass, MockModule(domain_base_exception, setup=base_exception_setup) + ) + mock_integration(hass, MockModule(domain_exception, setup=exception_setup)) + + # Set up the four components + assert await setup.async_setup_component(hass, domain_good, {}) + assert not await setup.async_setup_component(hass, domain_bad, {}) + assert not await setup.async_setup_component(hass, domain_exception, {}) + with pytest.raises(BaseException, match="fail!"): + await setup.async_setup_component(hass, domain_base_exception, {}) + + # Check the result of the setup + assert not hass.data[setup.DATA_SETUP_DONE] + assert set(hass.data[setup.DATA_SETUP]) == { + domain_bad, + domain_exception, + domain_base_exception, + } + assert set(hass.config.components) == {domain_good} + + # Calling async_set_domains_to_be_loaded again should not create any new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert not hass.data[setup.DATA_SETUP_DONE] async def test_component_setup_with_validation_and_dependency( diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 78f66ceb549..0b8fd20a7c0 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -1,6 +1,8 @@ """Test test fixture configuration.""" +from collections.abc import Generator from http import HTTPStatus +import pathlib import socket from aiohttp import web @@ -9,8 +11,11 @@ import pytest_socket from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.helpers import translation from homeassistant.setup import async_setup_component +from .common import MockModule, mock_integration +from .conftest import evict_faked_translations from .typing import ClientSessionGenerator @@ -70,3 +75,46 @@ async def test_aiohttp_client_frozen_router_view( assert response.status == HTTPStatus.OK result = await response.json() assert result["test"] is True + + +async def test_evict_faked_translations_assumptions(hass: HomeAssistant) -> None: + """Test assumptions made when detecting translations for mocked integrations. + + If this test fails, the evict_faked_translations may need to be updated. + """ + integration = mock_integration(hass, MockModule("test"), built_in=True) + assert integration.file_path == pathlib.Path("") + + +async def test_evict_faked_translations(hass: HomeAssistant, translations_once) -> None: + """Test the evict_faked_translations fixture.""" + cache: translation._TranslationsCacheData = translations_once.kwargs["return_value"] + fake_domain = "test" + real_domain = "homeassistant" + + # Evict the real domain from the cache in case it's been loaded before + cache.loaded["en"].discard(real_domain) + + assert fake_domain not in cache.loaded["en"] + assert real_domain not in cache.loaded["en"] + + # The evict_faked_translations fixture has module scope, so we set it up and + # tear it down manually + real_func = evict_faked_translations.__pytest_wrapped__.obj + gen: Generator = real_func(translations_once) + + # Set up the evict_faked_translations fixture + next(gen) + + mock_integration(hass, MockModule(fake_domain), built_in=True) + await translation.async_load_integrations(hass, {fake_domain, real_domain}) + assert fake_domain in cache.loaded["en"] + assert real_domain in cache.loaded["en"] + + # Tear down the evict_faked_translations fixture + with pytest.raises(StopIteration): + next(gen) + + # The mock integration should be removed from the cache, the real domain should still be there + assert fake_domain not in cache.loaded["en"] + assert real_domain in cache.loaded["en"] diff --git a/tests/typing.py b/tests/typing.py index 7b61949a9c4..5bcb1a01104 100644 --- a/tests/typing.py +++ b/tests/typing.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock @@ -30,6 +31,10 @@ type MqttMockHAClient = MagicMock """MagicMock for `homeassistant.components.mqtt.MQTT`.""" type MqttMockHAClientGenerator = Callable[..., Coroutine[Any, Any, MqttMockHAClient]] """MagicMock generator for `homeassistant.components.mqtt.MQTT`.""" +type RecorderInstanceContextManager = Callable[ + ..., AbstractAsyncContextManager[Recorder] +] +"""ContextManager for `homeassistant.components.recorder.Recorder`.""" type RecorderInstanceGenerator = Callable[..., Coroutine[Any, Any, Recorder]] """Instance generator for `homeassistant.components.recorder.Recorder`.""" type WebSocketGenerator = Callable[..., Coroutine[Any, Any, MockHAClientWebSocket]] diff --git a/tests/util/test_async.py b/tests/util/test_async.py index cfa78228f0c..e2310e6acd5 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -134,8 +134,10 @@ async def test_create_eager_task_312(hass: HomeAssistant) -> None: async def test_create_eager_task_from_thread(hass: HomeAssistant) -> None: """Test we report trying to create an eager task from a thread.""" + coro = asyncio.sleep(0) + def create_task(): - hasync.create_eager_task(asyncio.sleep(0)) + hasync.create_eager_task(coro) with pytest.raises( RuntimeError, @@ -145,14 +147,19 @@ async def test_create_eager_task_from_thread(hass: HomeAssistant) -> None: ): await hass.async_add_executor_job(create_task) + # Avoid `RuntimeWarning: coroutine 'sleep' was never awaited` + await coro + async def test_create_eager_task_from_thread_in_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we report trying to create an eager task from a thread.""" + coro = asyncio.sleep(0) + def create_task(): - hasync.create_eager_task(asyncio.sleep(0)) + hasync.create_eager_task(coro) frames = extract_stack_to_frame( [ @@ -200,6 +207,9 @@ async def test_create_eager_task_from_thread_in_integration( "self.light.is_on" ) in caplog.text + # Avoid `RuntimeWarning: coroutine 'sleep' was never awaited` + await coro + async def test_get_scheduled_timer_handles(hass: HomeAssistant) -> None: """Test get_scheduled_timer_handles returns all scheduled timer handles.""" diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 165552b8792..bcaf392b513 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util GAMUT = color_util.GamutType( color_util.XYPoint(0.704, 0.296), diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 347e92d6056..96ba8d0a325 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DEFAULT_TIME_ZONE = dt_util.get_default_time_zone() TEST_TIME_ZONE = "America/Los_Angeles" diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 759f0d6e5ea..111b086b48b 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util def test_raise_if_invalid_filename() -> None: diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 3af3ad2765a..ecb54eeeaa9 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -7,7 +7,7 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.util.location as location_util +from homeassistant.util import location as location_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 795444c89bd..e5b85f35693 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -14,7 +14,7 @@ from homeassistant.core import ( is_callback, is_callback_check_partial, ) -import homeassistant.util.logging as logging_util +from homeassistant.util import logging as logging_util async def test_logging_with_queue_handler() -> None: diff --git a/tests/util/test_network.py b/tests/util/test_network.py index 4bb6f94e684..c234a517640 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -2,7 +2,7 @@ from ipaddress import ip_address -import homeassistant.util.network as network_util +from homeassistant.util import network as network_util def test_is_loopback() -> None: diff --git a/tests/util/test_ulid.py b/tests/util/test_ulid.py index dc0f21ce3c7..6f9911fe557 100644 --- a/tests/util/test_ulid.py +++ b/tests/util/test_ulid.py @@ -2,7 +2,7 @@ import uuid -import homeassistant.util.ulid as ulid_util +from homeassistant.util import ulid as ulid_util async def test_ulid_util_uuid_hex() -> None: diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 9c123d93f62..aeea4ad9a5a 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -43,6 +44,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -79,6 +81,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { SpeedConverter, TemperatureConverter, UnitlessRatioConverter, + EnergyDistanceConverter, VolumeConverter, VolumeFlowRateConverter, ) @@ -115,6 +118,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 1000, ), EnergyConverter: (UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), + EnergyDistanceConverter: ( + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 0.621371, + ), InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8), MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), @@ -435,10 +443,24 @@ _CONVERTED_VALUE: dict[ ElectricPotentialConverter: [ (5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.VOLT, 5e6, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-3, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.VOLT, 5e-6, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MILLIVOLT, 0.005, UnitOfElectricPotential.VOLT), (5, UnitOfElectricPotential.MILLIVOLT, 5e3, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-6, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MILLIVOLT, 5e-9, UnitOfElectricPotential.MEGAVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-3, UnitOfElectricPotential.MILLIVOLT), (5, UnitOfElectricPotential.MICROVOLT, 5e-6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-9, UnitOfElectricPotential.KILOVOLT), + (5, UnitOfElectricPotential.MICROVOLT, 5e-12, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e9, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e6, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e3, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.KILOVOLT, 5e-3, UnitOfElectricPotential.MEGAVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e12, UnitOfElectricPotential.MICROVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e9, UnitOfElectricPotential.MILLIVOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e6, UnitOfElectricPotential.VOLT), + (5, UnitOfElectricPotential.MEGAVOLT, 5e3, UnitOfElectricPotential.KILOVOLT), ], EnergyConverter: [ (10, UnitOfEnergy.MILLIWATT_HOUR, 0.00001, UnitOfEnergy.KILO_WATT_HOUR), @@ -472,6 +494,38 @@ _CONVERTED_VALUE: dict[ (10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE), (10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR), ], + EnergyDistanceConverter: [ + ( + 10, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 6.213712, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ( + 25, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 4, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 20, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 3.106856, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + ), + ( + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ], InformationConverter: [ (8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS), (8e6, UnitOfInformation.BITS, 8, UnitOfInformation.MEGABITS), diff --git a/tests/util/test_uuid.py b/tests/util/test_uuid.py index e5a1022ef1d..9d78b634149 100644 --- a/tests/util/test_uuid.py +++ b/tests/util/test_uuid.py @@ -2,7 +2,7 @@ import uuid -import homeassistant.util.uuid as uuid_util +from homeassistant.util import uuid as uuid_util async def test_uuid_util_random_uuid_hex() -> None: diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 12a7eca5f9d..0346e21044f 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -15,7 +15,7 @@ import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader from tests.common import extract_stack_to_frame @@ -86,7 +86,7 @@ def test_unhashable_key() -> None: def test_no_key() -> None: """Test item without a key.""" with pytest.raises(HomeAssistantError): - yaml.load_yaml(YAML_CONFIG_FILE) + yaml_util.load_yaml(YAML_CONFIG_FILE) @pytest.mark.usefixtures("try_both_loaders") @@ -386,13 +386,13 @@ def test_load_yaml_encoding_error(mock_open: Mock) -> None: @pytest.mark.usefixtures("try_both_dumpers") def test_dump() -> None: """The that the dump method returns empty None values.""" - assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" + assert yaml_util.dump({"a": None, "b": "b"}) == "a:\nb: b\n" @pytest.mark.usefixtures("try_both_dumpers") def test_dump_unicode() -> None: """The that the dump method returns empty None values.""" - assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" + assert yaml_util.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @pytest.mark.parametrize("hass_config_yaml", ['key: [1, "2", 3]']) @@ -400,7 +400,7 @@ def test_dump_unicode() -> None: def test_representing_yaml_loaded_data() -> None: """Test we can represent YAML loaded data.""" data = load_yaml_config_file(YAML_CONFIG_FILE) - assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" + assert yaml_util.dump(data) == "key:\n- 1\n- '2'\n- 3\n" @pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) @@ -413,7 +413,7 @@ def test_duplicate_key(caplog: pytest.LogCaptureFixture) -> None: @pytest.mark.parametrize( "hass_config_yaml_files", - [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], + [{YAML_CONFIG_FILE: "key: !secret a", yaml_util.SECRET_YAML: "a: 1\nb: !secret a"}], ) @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_no_recursive_secrets() -> None: @@ -426,8 +426,8 @@ def test_no_recursive_secrets() -> None: def test_input_class() -> None: """Test input class.""" - yaml_input = yaml.Input("hello") - yaml_input2 = yaml.Input("hello") + yaml_input = yaml_util.Input("hello") + yaml_input2 = yaml_util.Input("hello") assert yaml_input.name == "hello" assert yaml_input == yaml_input2 @@ -438,8 +438,8 @@ def test_input_class() -> None: @pytest.mark.usefixtures("try_both_loaders", "try_both_dumpers") def test_input() -> None: """Test loading inputs.""" - data = {"hello": yaml.Input("test_name")} - assert yaml.parse_yaml(yaml.dump(data)) == data + data = {"hello": yaml_util.Input("test_name")} + assert yaml_util.parse_yaml(yaml_util.dump(data)) == data @pytest.mark.skipif( @@ -448,7 +448,7 @@ def test_input() -> None: ) def test_c_loader_is_available_in_ci() -> None: """Verify we are testing the C loader in the CI.""" - assert yaml.loader.HAS_C_LOADER is True + assert yaml_util.loader.HAS_C_LOADER is True @pytest.mark.usefixtures("try_both_loaders") @@ -552,7 +552,7 @@ def test_string_used_as_vol_schema() -> None: @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_load_yaml_dict(expected_data: Any) -> None: """Test item without a key.""" - assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + assert yaml_util.load_yaml_dict(YAML_CONFIG_FILE) == expected_data @pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) diff --git a/tests/util/yaml/test_secrets.py b/tests/util/yaml/test_secrets.py index 35b5ae319c4..4d89bfb8712 100644 --- a/tests/util/yaml/test_secrets.py +++ b/tests/util/yaml/test_secrets.py @@ -8,7 +8,7 @@ import pytest from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import yaml +from homeassistant.util import yaml as yaml_util from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_config_dir, patch_yaml_files @@ -63,7 +63,7 @@ def default_config(filepaths: dict[str, Path]) -> YamlFile: def default_secrets(filepaths: dict[str, Path]) -> YamlFile: """Return the default secrets file for testing.""" return YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, + path=filepaths["config"] / yaml_util.SECRET_YAML, contents=( "http_pw: pwhttp\n" "comp1_un: un1\n" @@ -112,7 +112,8 @@ def test_secret_overrides_parent( path=filepaths["sub_folder"] / "sub.yaml", contents=default_config.contents ) sub_secrets = YamlFile( - path=filepaths["sub_folder"] / yaml.SECRET_YAML, contents="http_pw: override" + path=filepaths["sub_folder"] / yaml_util.SECRET_YAML, + contents="http_pw: override", ) loaded_file = load_config_file( @@ -133,7 +134,7 @@ def test_secrets_from_unrelated_fails( contents="http:\n api_password: !secret test", ) unrelated_secrets = YamlFile( - path=filepaths["unrelated"] / yaml.SECRET_YAML, contents="test: failure" + path=filepaths["unrelated"] / yaml_util.SECRET_YAML, contents="test: failure" ) with pytest.raises(HomeAssistantError, match="Secret test not defined"): load_config_file( @@ -162,7 +163,8 @@ def test_bad_logger_value( path=filepaths["config"] / YAML_CONFIG_FILE, contents="api_password: !secret pw" ) secrets_file = YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, contents="logger: info\npw: abc" + path=filepaths["config"] / yaml_util.SECRET_YAML, + contents="logger: info\npw: abc", ) with caplog.at_level(logging.ERROR): load_config_file(config_file.path, [config_file, secrets_file]) @@ -178,7 +180,7 @@ def test_secrets_are_not_dict( ) -> None: """Did secrets handle non-dict file.""" non_dict_secrets = YamlFile( - path=filepaths["config"] / yaml.SECRET_YAML, + path=filepaths["config"] / yaml_util.SECRET_YAML, contents="- http_pw: pwhttp\n comp1_un: un1\n comp1_pw: pw1\n", ) with pytest.raises(HomeAssistantError, match="Secrets is not a dictionary"):